@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequent-org/moodboard",
3
- "version": "1.4.58",
3
+ "version": "1.4.59",
4
4
  "type": "module",
5
5
  "description": "Interactive moodboard",
6
6
  "main": "./src/index.js",
@@ -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
  );
@@ -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);
@@ -63,6 +63,7 @@ export const Events = {
63
63
  PasteAt: 'ui:paste-at',
64
64
  PasteImage: 'ui:paste-image',
65
65
  PasteImageAt: 'ui:paste-image-at',
66
+ PasteVideoAt: 'ui:paste-video-at',
66
67
  ZoomIn: 'ui:zoom:in',
67
68
  ZoomOut: 'ui:zoom:out',
68
69
  ZoomReset: 'ui:zoom:reset',
@@ -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
- // Тип объекта «видео» на доске не существует. Событие Events.UI.PasteImageAt
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
- zIndex: 26,
139
+ // Выше панелей свойств (text-properties-layer = 10050, *PropertiesPanel = 10000),
140
+ // иначе открытый поверх объекта попап комментария оказывается под тулбаром свойств.
141
+ zIndex: 10060,
140
142
  });
141
143
  this.container.appendChild(this.layer);
142
144