@sequent-org/moodboard 1.4.58 → 1.4.59
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/ApiClient.js +41 -2
- package/src/core/PixiEngine.js +10 -1
- package/src/core/events/Events.js +1 -0
- package/src/core/flows/ClipboardFlow.js +48 -0
- package/src/objects/ObjectFactory.js +2 -0
- package/src/objects/VideoObject.js +171 -0
- package/src/ui/chat/ChatWindow.js +39 -8
- package/src/ui/comments/CommentThreadPopover.js +3 -1
package/package.json
CHANGED
package/src/core/ApiClient.js
CHANGED
|
@@ -241,7 +241,30 @@ export class ApiClient {
|
|
|
241
241
|
}
|
|
242
242
|
return cleanedObj;
|
|
243
243
|
}
|
|
244
|
-
|
|
244
|
+
|
|
245
|
+
if (obj.type === 'video') {
|
|
246
|
+
const topSrcRaw = typeof obj.src === 'string' ? obj.src : '';
|
|
247
|
+
const propSrcRaw = typeof obj.properties?.src === 'string' ? obj.properties.src : '';
|
|
248
|
+
const normalizedSrc = topSrcRaw.trim() || propSrcRaw.trim();
|
|
249
|
+
|
|
250
|
+
if (!normalizedSrc) {
|
|
251
|
+
throw new Error(`Video object "${obj.id || 'unknown'}" has no src. Save is blocked.`);
|
|
252
|
+
}
|
|
253
|
+
if (/^data:/i.test(normalizedSrc) || /^blob:/i.test(normalizedSrc)) {
|
|
254
|
+
throw new Error(`Video object "${obj.id || 'unknown'}" contains forbidden data/blob src. Save is blocked.`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const cleanedObj = {
|
|
258
|
+
...obj,
|
|
259
|
+
src: normalizedSrc
|
|
260
|
+
};
|
|
261
|
+
if (cleanedObj.properties?.src) {
|
|
262
|
+
cleanedObj.properties = { ...cleanedObj.properties };
|
|
263
|
+
delete cleanedObj.properties.src;
|
|
264
|
+
}
|
|
265
|
+
return cleanedObj;
|
|
266
|
+
}
|
|
267
|
+
|
|
245
268
|
return obj;
|
|
246
269
|
});
|
|
247
270
|
|
|
@@ -296,7 +319,23 @@ export class ApiClient {
|
|
|
296
319
|
}
|
|
297
320
|
return restoredObj;
|
|
298
321
|
}
|
|
299
|
-
|
|
322
|
+
|
|
323
|
+
if (obj.type === 'video') {
|
|
324
|
+
const topSrc = typeof obj.src === 'string' ? obj.src.trim() : '';
|
|
325
|
+
const propSrc = typeof obj.properties?.src === 'string' ? obj.properties.src.trim() : '';
|
|
326
|
+
const normalizedSrc = topSrc || propSrc;
|
|
327
|
+
if (!normalizedSrc) return obj;
|
|
328
|
+
const restoredObj = {
|
|
329
|
+
...obj,
|
|
330
|
+
src: normalizedSrc
|
|
331
|
+
};
|
|
332
|
+
if (restoredObj.properties?.src) {
|
|
333
|
+
restoredObj.properties = { ...restoredObj.properties };
|
|
334
|
+
delete restoredObj.properties.src;
|
|
335
|
+
}
|
|
336
|
+
return restoredObj;
|
|
337
|
+
}
|
|
338
|
+
|
|
300
339
|
return obj;
|
|
301
340
|
})
|
|
302
341
|
);
|
package/src/core/PixiEngine.js
CHANGED
|
@@ -248,7 +248,16 @@ export class PixiEngine {
|
|
|
248
248
|
const pixiObject = this.objects.get(objectId);
|
|
249
249
|
if (pixiObject) {
|
|
250
250
|
console.log('🗑️ PixiEngine: удаляем объект из сцены:', objectId);
|
|
251
|
-
|
|
251
|
+
|
|
252
|
+
// Видео: останавливаем HTMLVideoElement и снимаем listeners до уничтожения
|
|
253
|
+
// спрайта. Без этого видео продолжает играть и тикать текстуру — утечка.
|
|
254
|
+
// Хук сужен до 'video', чтобы не задеть destroy() фреймов/коннекторов,
|
|
255
|
+
// у которых контейнер уничтожается общим путём ниже (иначе двойной destroy).
|
|
256
|
+
const mbMeta = pixiObject._mb;
|
|
257
|
+
if (mbMeta?.type === 'video' && typeof mbMeta.instance?.destroy === 'function') {
|
|
258
|
+
try { mbMeta.instance.destroy(); } catch (_) {}
|
|
259
|
+
}
|
|
260
|
+
|
|
252
261
|
// Удаляем из родительского контейнера
|
|
253
262
|
if (this.worldLayer) {
|
|
254
263
|
this.worldLayer.removeChild(pixiObject);
|
|
@@ -380,6 +380,54 @@ export function setupClipboardFlow(core) {
|
|
|
380
380
|
}
|
|
381
381
|
});
|
|
382
382
|
|
|
383
|
+
core.eventBus.on(Events.UI.PasteVideoAt, ({ x, y, src, mimeType, poster, aiMessageId, skipUpload = true }) => {
|
|
384
|
+
if (!src) return;
|
|
385
|
+
const world = core.pixi.worldLayer || core.pixi.app.stage;
|
|
386
|
+
const s = world?.scale?.x || 1;
|
|
387
|
+
const worldX = (x - (world?.x || 0)) / s;
|
|
388
|
+
const worldY = (y - (world?.y || 0)) / s;
|
|
389
|
+
|
|
390
|
+
const placeVideo = (natW, natH) => {
|
|
391
|
+
const ar = (natW > 0 && natH > 0) ? natW / natH : (16 / 9);
|
|
392
|
+
const w = 300;
|
|
393
|
+
const h = Math.max(1, Math.round(w / ar));
|
|
394
|
+
const created = core.createObject(
|
|
395
|
+
'video',
|
|
396
|
+
{ x: Math.round(worldX - Math.round(w / 2)), y: Math.round(worldY - Math.round(h / 2)) },
|
|
397
|
+
{
|
|
398
|
+
src,
|
|
399
|
+
width: w,
|
|
400
|
+
height: h,
|
|
401
|
+
mimeType: mimeType || 'video/mp4',
|
|
402
|
+
poster: poster || null,
|
|
403
|
+
loop: true,
|
|
404
|
+
muted: true,
|
|
405
|
+
...(aiMessageId ? { aiMessageId } : {})
|
|
406
|
+
}
|
|
407
|
+
);
|
|
408
|
+
// Видео не проходит через SaveManager до подтверждения — раскрываем сразу,
|
|
409
|
+
// как для AI-картинок и 3D-скринов.
|
|
410
|
+
if (skipUpload && created?.id) {
|
|
411
|
+
core._pendingPersistAckVisibilityIds?.delete(created.id);
|
|
412
|
+
core._setObjectVisibility?.(created.id, true);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
// Узнаём натуральные пропорции из метаданных видео (для aspect-ratio).
|
|
417
|
+
try {
|
|
418
|
+
const probe = document.createElement('video');
|
|
419
|
+
probe.preload = 'metadata';
|
|
420
|
+
probe.muted = true;
|
|
421
|
+
probe.addEventListener('loadedmetadata', () => {
|
|
422
|
+
placeVideo(probe.videoWidth || 0, probe.videoHeight || 0);
|
|
423
|
+
}, { once: true });
|
|
424
|
+
probe.addEventListener('error', () => placeVideo(0, 0), { once: true });
|
|
425
|
+
probe.src = src;
|
|
426
|
+
} catch (_) {
|
|
427
|
+
placeVideo(0, 0);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
383
431
|
core.eventBus.on(Events.Tool.DuplicateRequest, (data) => {
|
|
384
432
|
const { originalId, position } = data || {};
|
|
385
433
|
if (!originalId) return;
|
|
@@ -4,6 +4,7 @@ import { DrawingObject } from './DrawingObject.js';
|
|
|
4
4
|
import { TextObject } from './TextObject.js';
|
|
5
5
|
import { EmojiObject } from './EmojiObject.js';
|
|
6
6
|
import { ImageObject } from './ImageObject.js';
|
|
7
|
+
import { VideoObject } from './VideoObject.js';
|
|
7
8
|
import { RevitScreenshotImageObject } from './RevitScreenshotImageObject.js';
|
|
8
9
|
import { Model3dScreenshotImageObject } from './Model3dScreenshotImageObject.js';
|
|
9
10
|
import { CommentObject } from './CommentObject.js';
|
|
@@ -25,6 +26,7 @@ export class ObjectFactory {
|
|
|
25
26
|
['simple-text', TextObject],
|
|
26
27
|
['emoji', EmojiObject],
|
|
27
28
|
['image', ImageObject],
|
|
29
|
+
['video', VideoObject],
|
|
28
30
|
['revit-screenshot-img', RevitScreenshotImageObject],
|
|
29
31
|
['model3d-screenshot-img', Model3dScreenshotImageObject],
|
|
30
32
|
['comment', CommentObject],
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import * as PIXI from 'pixi.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* VideoObject — отображение видео как спрайт с видео-текстурой.
|
|
5
|
+
*
|
|
6
|
+
* MVP: спрайт с muted-видео, клик по телу переключает play/pause.
|
|
7
|
+
* Трансформации (resize/rotate/move/z-order) переиспользуют конвейер ImageObject:
|
|
8
|
+
* единственный display-object — Sprite с anchor(0.5).
|
|
9
|
+
*/
|
|
10
|
+
export class VideoObject {
|
|
11
|
+
constructor(objectData = {}) {
|
|
12
|
+
this.objectData = objectData;
|
|
13
|
+
let src = objectData.src || objectData.properties?.src;
|
|
14
|
+
// blob: недолговечны — не используем (ERR_FILE_NOT_FOUND после перезагрузки)
|
|
15
|
+
if (typeof src === 'string' && src.startsWith('blob:')) {
|
|
16
|
+
src = null;
|
|
17
|
+
}
|
|
18
|
+
this.src = src;
|
|
19
|
+
this.width = Math.max(1, Math.round(objectData.width || objectData.properties?.width || 300));
|
|
20
|
+
this.height = Math.max(1, Math.round(objectData.height || objectData.properties?.height || 169));
|
|
21
|
+
|
|
22
|
+
this.videoEl = null;
|
|
23
|
+
let texture = PIXI.Texture.WHITE;
|
|
24
|
+
if (src) {
|
|
25
|
+
const video = document.createElement('video');
|
|
26
|
+
video.src = src;
|
|
27
|
+
video.muted = true;
|
|
28
|
+
video.loop = objectData.properties?.loop ?? true;
|
|
29
|
+
video.playsInline = true;
|
|
30
|
+
video.crossOrigin = 'anonymous';
|
|
31
|
+
video.preload = 'auto';
|
|
32
|
+
this.videoEl = video;
|
|
33
|
+
// autoPlay:false — стартуем на паузе, первый кадр показываем после loadeddata
|
|
34
|
+
texture = PIXI.Texture.from(video, { resourceOptions: { autoPlay: false } });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.sprite = new PIXI.Sprite(texture);
|
|
38
|
+
this.sprite.anchor.set(0.5, 0.5);
|
|
39
|
+
if (!src) {
|
|
40
|
+
this.sprite.tint = 0xcccccc;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this._fitToSize = () => {
|
|
44
|
+
const texW = this.sprite.texture.width || 1;
|
|
45
|
+
const texH = this.sprite.texture.height || 1;
|
|
46
|
+
this.sprite._mb = {
|
|
47
|
+
...(this.sprite._mb || {}),
|
|
48
|
+
type: this.objectData?.type || 'video',
|
|
49
|
+
properties: {
|
|
50
|
+
...((this.sprite._mb || {}).properties || {}),
|
|
51
|
+
src,
|
|
52
|
+
baseW: texW,
|
|
53
|
+
baseH: texH
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
this.sprite.scale.set(this.width / texW, this.height / texH);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const onError = () => {
|
|
60
|
+
this.sprite.texture = PIXI.Texture.WHITE;
|
|
61
|
+
this.sprite.tint = 0xcccccc;
|
|
62
|
+
this._fitToSize();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (this.videoEl) {
|
|
66
|
+
// Показать первый кадр на паузе: после loadeddata толкнуть кадр в GPU
|
|
67
|
+
this.videoEl.addEventListener('loadeddata', () => {
|
|
68
|
+
try { this.sprite.texture.update(); } catch (_) {}
|
|
69
|
+
this._fitToSize();
|
|
70
|
+
}, { once: true });
|
|
71
|
+
this.videoEl.addEventListener('error', onError, { once: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (this.sprite.texture.baseTexture?.valid) {
|
|
75
|
+
this._fitToSize();
|
|
76
|
+
} else if (this.sprite.texture.baseTexture) {
|
|
77
|
+
this.sprite.texture.baseTexture.once('loaded', this._fitToSize);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this._setupPlaybackToggle();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Клик по телу видео (без перетаскивания) переключает play/pause.
|
|
85
|
+
* Порог движения отсекает drag, чтобы перемещение не триггерило тоггл.
|
|
86
|
+
*/
|
|
87
|
+
_setupPlaybackToggle() {
|
|
88
|
+
this.sprite.eventMode = 'static';
|
|
89
|
+
let down = null;
|
|
90
|
+
this._onDown = (e) => { down = { x: e.global.x, y: e.global.y }; };
|
|
91
|
+
this._onUp = (e) => {
|
|
92
|
+
if (!down) return;
|
|
93
|
+
const dx = e.global.x - down.x;
|
|
94
|
+
const dy = e.global.y - down.y;
|
|
95
|
+
down = null;
|
|
96
|
+
if (Math.hypot(dx, dy) < 5) {
|
|
97
|
+
this.toggle();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
this.sprite.on('pointerdown', this._onDown);
|
|
101
|
+
this.sprite.on('pointerup', this._onUp);
|
|
102
|
+
this.sprite.on('pointerupoutside', () => { down = null; });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
play() {
|
|
106
|
+
if (!this.videoEl) return;
|
|
107
|
+
const p = this.videoEl.play();
|
|
108
|
+
if (p && typeof p.catch === 'function') {
|
|
109
|
+
p.catch(() => {});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
pause() {
|
|
114
|
+
if (this.videoEl) {
|
|
115
|
+
this.videoEl.pause();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
toggle() {
|
|
120
|
+
if (!this.videoEl) return;
|
|
121
|
+
if (this.videoEl.paused) {
|
|
122
|
+
this.play();
|
|
123
|
+
} else {
|
|
124
|
+
this.pause();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
getPixi() {
|
|
129
|
+
return this.sprite;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
updateSize(size) {
|
|
133
|
+
if (!size) return;
|
|
134
|
+
this.width = Math.max(1, Math.round(size.width || 1));
|
|
135
|
+
this.height = Math.max(1, Math.round(size.height || 1));
|
|
136
|
+
const apply = () => {
|
|
137
|
+
const texW = this.sprite.texture.width || 1;
|
|
138
|
+
const texH = this.sprite.texture.height || 1;
|
|
139
|
+
this.sprite.scale.set(this.width / texW, this.height / texH);
|
|
140
|
+
};
|
|
141
|
+
if (this.sprite.texture.baseTexture?.valid) {
|
|
142
|
+
apply();
|
|
143
|
+
} else {
|
|
144
|
+
this.sprite.texture.baseTexture?.once('loaded', apply);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Остановить видео и освободить ресурсы (вызывается при удалении объекта).
|
|
150
|
+
* Без этого HTMLVideoElement продолжает жить и тикать текстуру — утечка.
|
|
151
|
+
*/
|
|
152
|
+
destroy() {
|
|
153
|
+
if (this.videoEl) {
|
|
154
|
+
try {
|
|
155
|
+
this.videoEl.pause();
|
|
156
|
+
this.videoEl.removeAttribute('src');
|
|
157
|
+
this.videoEl.load();
|
|
158
|
+
} catch (_) {}
|
|
159
|
+
this.videoEl = null;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
this.sprite?.off('pointerdown', this._onDown);
|
|
163
|
+
this.sprite?.off('pointerup', this._onUp);
|
|
164
|
+
} catch (_) {}
|
|
165
|
+
try {
|
|
166
|
+
if (this.sprite?.texture && this.sprite.texture !== PIXI.Texture.WHITE) {
|
|
167
|
+
this.sprite.texture.destroy(true);
|
|
168
|
+
}
|
|
169
|
+
} catch (_) {}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -972,6 +972,43 @@ export class ChatWindow {
|
|
|
972
972
|
});
|
|
973
973
|
}
|
|
974
974
|
|
|
975
|
+
_placeGeneratedVideoOnBoard(result, skeletonWorld = null) {
|
|
976
|
+
if (!this._boardCore?.eventBus) return;
|
|
977
|
+
const src = result?.videoUrl;
|
|
978
|
+
if (!src) {
|
|
979
|
+
if (this._refs?.errorBlock) {
|
|
980
|
+
this._refs.errorBlock.textContent = 'Видео сгенерировано, но ссылка отсутствует.';
|
|
981
|
+
this._refs.errorBlock.classList.add('is-visible');
|
|
982
|
+
}
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
const world = this._boardCore.pixi?.worldLayer || this._boardCore.pixi?.app?.stage;
|
|
987
|
+
const s = world?.scale?.x || 1;
|
|
988
|
+
|
|
989
|
+
// Приоритет — мировая точка скелетона: видео приземляется туда, где была заглушка,
|
|
990
|
+
// даже если пользователь панорамировал/зумил во время генерации.
|
|
991
|
+
let x;
|
|
992
|
+
let y;
|
|
993
|
+
if (skeletonWorld) {
|
|
994
|
+
x = Math.round(skeletonWorld.x * s + (world?.x || 0));
|
|
995
|
+
y = Math.round(skeletonWorld.y * s + (world?.y || 0));
|
|
996
|
+
} else {
|
|
997
|
+
const composerRect = this._refs?.composer?.getBoundingClientRect?.();
|
|
998
|
+
x = composerRect ? Math.round(composerRect.left + composerRect.width / 2) : 400;
|
|
999
|
+
y = composerRect ? Math.round(composerRect.top - 200) : 200;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
this._boardCore.eventBus.emit(Events.UI.PasteVideoAt, {
|
|
1003
|
+
x,
|
|
1004
|
+
y,
|
|
1005
|
+
src,
|
|
1006
|
+
mimeType: result?.mimeType || 'video/mp4',
|
|
1007
|
+
poster: null,
|
|
1008
|
+
skipUpload: true
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
|
|
975
1012
|
_clearBoardSelection() {
|
|
976
1013
|
if (typeof this._boardCore?.selectTool?.clearSelection === 'function') {
|
|
977
1014
|
this._boardCore.selectTool.clearSelection();
|
|
@@ -1227,15 +1264,9 @@ export class ChatWindow {
|
|
|
1227
1264
|
}
|
|
1228
1265
|
if (state.status === 'done') {
|
|
1229
1266
|
this._composer?.setStreaming(false);
|
|
1267
|
+
const skeletonWorld = this._videoSkeletonWorld;
|
|
1230
1268
|
this._clearVideoSkeleton();
|
|
1231
|
-
|
|
1232
|
-
// ожидает data URL картинки — видео-URL несовместим. Размещение на доске
|
|
1233
|
-
// выходит за рамки текущей фазы (нет объекта типа video).
|
|
1234
|
-
console.info('[ChatWindow] Видео готово. Размещение на доске не поддерживается: нет объекта типа video.', state.result?.videoUrl);
|
|
1235
|
-
if (this._refs?.errorBlock) {
|
|
1236
|
-
this._refs.errorBlock.textContent = 'Видео сгенерировано. Размещение на доске появится в следующей фазе.';
|
|
1237
|
-
this._refs.errorBlock.classList.add('is-visible');
|
|
1238
|
-
}
|
|
1269
|
+
this._placeGeneratedVideoOnBoard(state.result, skeletonWorld);
|
|
1239
1270
|
}
|
|
1240
1271
|
if (state.status === 'error') {
|
|
1241
1272
|
this._composer?.setStreaming(false);
|
|
@@ -136,7 +136,9 @@ export class CommentThreadPopover {
|
|
|
136
136
|
position: 'absolute',
|
|
137
137
|
inset: '0',
|
|
138
138
|
pointerEvents: 'none',
|
|
139
|
-
|
|
139
|
+
// Выше панелей свойств (text-properties-layer = 10050, *PropertiesPanel = 10000),
|
|
140
|
+
// иначе открытый поверх объекта попап комментария оказывается под тулбаром свойств.
|
|
141
|
+
zIndex: 10060,
|
|
140
142
|
});
|
|
141
143
|
this.container.appendChild(this.layer);
|
|
142
144
|
|