@operato/board 10.0.0-beta.28 → 10.0.0-beta.29
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/CHANGELOG.md +14 -0
- package/dist/src/graphql/playback-buffer.d.ts +79 -0
- package/dist/src/graphql/playback-buffer.js +139 -0
- package/dist/src/graphql/playback-buffer.js.map +1 -0
- package/dist/src/graphql/playback-buffer.test.d.ts +1 -0
- package/dist/src/graphql/playback-buffer.test.js +261 -0
- package/dist/src/graphql/playback-buffer.test.js.map +1 -0
- package/dist/src/graphql/playback-subscription.d.ts +33 -18
- package/dist/src/graphql/playback-subscription.js +158 -91
- package/dist/src/graphql/playback-subscription.js.map +1 -1
- package/dist/src/ox-playback-controls.d.ts +8 -0
- package/dist/src/ox-playback-controls.js +114 -18
- package/dist/src/ox-playback-controls.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -8
|
@@ -4,6 +4,10 @@ export interface PlaybackStatus {
|
|
|
4
4
|
state: PlaybackState;
|
|
5
5
|
currentTime: string;
|
|
6
6
|
speed: number;
|
|
7
|
+
bufferedRanges?: {
|
|
8
|
+
from: number;
|
|
9
|
+
to: number;
|
|
10
|
+
}[];
|
|
7
11
|
}
|
|
8
12
|
export interface PlaybackConfig {
|
|
9
13
|
/** 플레이백 가능한 시간 범위 */
|
|
@@ -13,62 +17,73 @@ export interface PlaybackConfig {
|
|
|
13
17
|
};
|
|
14
18
|
}
|
|
15
19
|
/**
|
|
16
|
-
* PlaybackProvider
|
|
20
|
+
* PlaybackProvider — YouTube 스트리밍 방식의 청크 기반 플레이백
|
|
17
21
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* 실시간 DataSubscriptionProviderImpl과 동일한 인터페이스를 구현하므로
|
|
23
|
-
* Scene 입장에서는 실시간/플레이백을 구분하지 않는다.
|
|
22
|
+
* - 10분 단위로 데이터를 fetch하여 버퍼에 적재
|
|
23
|
+
* - 남은 데이터 1분 이하 시 다음 10분 prefetch
|
|
24
|
+
* - seek 시 기존 버퍼 전체 폐기 후 fresh fetch
|
|
25
|
+
* - requestAnimationFrame 기반 재생 루프
|
|
24
26
|
*/
|
|
25
27
|
export declare class PlaybackProvider implements DataSubscriptionProvider {
|
|
26
28
|
private _components;
|
|
27
|
-
private
|
|
29
|
+
private _buffer;
|
|
28
30
|
private _state;
|
|
29
31
|
private _speed;
|
|
30
|
-
private
|
|
32
|
+
private _playHead;
|
|
33
|
+
private _lastFrameTime;
|
|
34
|
+
private _rafId;
|
|
35
|
+
private _lastDistributedSnapshot;
|
|
31
36
|
private _onStatusChange?;
|
|
37
|
+
private _totalRange;
|
|
32
38
|
constructor(onStatusChange?: (status: PlaybackStatus) => void);
|
|
33
39
|
get state(): PlaybackState;
|
|
34
40
|
get speed(): number;
|
|
35
41
|
get currentTime(): string;
|
|
36
42
|
/**
|
|
37
43
|
* DataSubscriptionProvider.subscribe 구현
|
|
38
|
-
* 컴포넌트를 tag별로 등록한다. 실제 데이터는 백엔드 구독을 통해 수신된다.
|
|
39
44
|
*/
|
|
40
45
|
subscribe(tag: string, component: Component): Promise<{
|
|
41
46
|
unsubscribe: () => void;
|
|
42
47
|
}>;
|
|
43
48
|
/**
|
|
44
|
-
* 플레이백 시작
|
|
49
|
+
* 플레이백 시작
|
|
45
50
|
*/
|
|
46
|
-
start(fromTime: Date, speed?: number
|
|
51
|
+
start(fromTime: Date, speed?: number, totalRange?: {
|
|
52
|
+
from: Date;
|
|
53
|
+
to: Date;
|
|
54
|
+
}): Promise<void>;
|
|
47
55
|
/**
|
|
48
56
|
* 일시정지
|
|
49
57
|
*/
|
|
50
|
-
pause():
|
|
58
|
+
pause(): void;
|
|
51
59
|
/**
|
|
52
60
|
* 재개
|
|
53
61
|
*/
|
|
54
|
-
resume():
|
|
62
|
+
resume(): void;
|
|
55
63
|
/**
|
|
56
|
-
*
|
|
64
|
+
* seek — 기존 버퍼 폐기 + 새 위치에서 fresh fetch
|
|
57
65
|
*/
|
|
58
66
|
seek(toTime: Date): Promise<void>;
|
|
59
67
|
/**
|
|
60
68
|
* 배속 변경
|
|
61
69
|
*/
|
|
62
|
-
setSpeed(speed: number):
|
|
70
|
+
setSpeed(speed: number): void;
|
|
63
71
|
/**
|
|
64
|
-
*
|
|
72
|
+
* 중지
|
|
65
73
|
*/
|
|
66
74
|
stop(): void;
|
|
67
75
|
/**
|
|
68
76
|
* DataSubscriptionProvider.dispose 구현
|
|
69
77
|
*/
|
|
70
78
|
dispose(): void;
|
|
79
|
+
private _startPlayLoop;
|
|
80
|
+
private _stopPlayLoop;
|
|
81
|
+
private _playLoop;
|
|
71
82
|
private _distributeSnapshot;
|
|
72
|
-
private
|
|
83
|
+
private _createFetcher;
|
|
84
|
+
/**
|
|
85
|
+
* 백엔드 응답을 PlaybackSnapshot[] 형식으로 변환
|
|
86
|
+
*/
|
|
87
|
+
private _parsePlaybackResponse;
|
|
73
88
|
private _notifyStatus;
|
|
74
89
|
}
|
|
@@ -1,36 +1,56 @@
|
|
|
1
1
|
import gql from 'graphql-tag';
|
|
2
|
-
import {
|
|
2
|
+
import { PlaybackBuffer } from './playback-buffer.js';
|
|
3
3
|
/**
|
|
4
|
-
* PlaybackProvider
|
|
4
|
+
* PlaybackProvider — YouTube 스트리밍 방식의 청크 기반 플레이백
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 실시간 DataSubscriptionProviderImpl과 동일한 인터페이스를 구현하므로
|
|
11
|
-
* Scene 입장에서는 실시간/플레이백을 구분하지 않는다.
|
|
6
|
+
* - 10분 단위로 데이터를 fetch하여 버퍼에 적재
|
|
7
|
+
* - 남은 데이터 1분 이하 시 다음 10분 prefetch
|
|
8
|
+
* - seek 시 기존 버퍼 전체 폐기 후 fresh fetch
|
|
9
|
+
* - requestAnimationFrame 기반 재생 루프
|
|
12
10
|
*/
|
|
13
11
|
export class PlaybackProvider {
|
|
14
12
|
constructor(onStatusChange) {
|
|
15
13
|
this._components = new Map();
|
|
16
|
-
this.
|
|
14
|
+
this._buffer = null;
|
|
17
15
|
this._state = 'idle';
|
|
18
16
|
this._speed = 1;
|
|
19
|
-
this.
|
|
17
|
+
this._playHead = 0;
|
|
18
|
+
this._lastFrameTime = 0;
|
|
19
|
+
this._rafId = 0;
|
|
20
|
+
this._lastDistributedSnapshot = null;
|
|
21
|
+
this._totalRange = null;
|
|
22
|
+
this._playLoop = (now) => {
|
|
23
|
+
if (this._state !== 'playing' || !this._buffer)
|
|
24
|
+
return;
|
|
25
|
+
const delta = (now - this._lastFrameTime) * this._speed;
|
|
26
|
+
this._lastFrameTime = now;
|
|
27
|
+
this._playHead += delta;
|
|
28
|
+
// 전체 범위 끝 도달 체크
|
|
29
|
+
if (this._totalRange && this._playHead >= this._totalRange.to.getTime()) {
|
|
30
|
+
this._playHead = this._totalRange.to.getTime();
|
|
31
|
+
this._state = 'stopped';
|
|
32
|
+
this._notifyStatus();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
// 현재 스냅샷 배포
|
|
36
|
+
const snap = this._buffer.getSnapshotAt(this._playHead);
|
|
37
|
+
if (snap && snap !== this._lastDistributedSnapshot) {
|
|
38
|
+
this._distributeSnapshot(snap.data);
|
|
39
|
+
this._lastDistributedSnapshot = snap;
|
|
40
|
+
}
|
|
41
|
+
// prefetch 체크 (비동기, 루프 블로킹 안 함)
|
|
42
|
+
this._buffer.checkPrefetch(this._playHead);
|
|
43
|
+
this._notifyStatus();
|
|
44
|
+
// 다음 프레임
|
|
45
|
+
this._rafId = requestAnimationFrame(this._playLoop);
|
|
46
|
+
};
|
|
20
47
|
this._onStatusChange = onStatusChange;
|
|
21
48
|
}
|
|
22
|
-
get state() {
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
get speed() {
|
|
26
|
-
return this._speed;
|
|
27
|
-
}
|
|
28
|
-
get currentTime() {
|
|
29
|
-
return this._currentTime;
|
|
30
|
-
}
|
|
49
|
+
get state() { return this._state; }
|
|
50
|
+
get speed() { return this._speed; }
|
|
51
|
+
get currentTime() { return this._playHead ? new Date(this._playHead).toISOString() : ''; }
|
|
31
52
|
/**
|
|
32
53
|
* DataSubscriptionProvider.subscribe 구현
|
|
33
|
-
* 컴포넌트를 tag별로 등록한다. 실제 데이터는 백엔드 구독을 통해 수신된다.
|
|
34
54
|
*/
|
|
35
55
|
async subscribe(tag, component) {
|
|
36
56
|
if (!this._components.has(tag)) {
|
|
@@ -50,94 +70,85 @@ export class PlaybackProvider {
|
|
|
50
70
|
};
|
|
51
71
|
}
|
|
52
72
|
/**
|
|
53
|
-
* 플레이백 시작
|
|
73
|
+
* 플레이백 시작
|
|
54
74
|
*/
|
|
55
|
-
async start(fromTime, speed = 1) {
|
|
56
|
-
|
|
57
|
-
// 기존 구독이 있으면 정리
|
|
58
|
-
(_a = this._subscription) === null || _a === void 0 ? void 0 : _a.unsubscribe();
|
|
75
|
+
async start(fromTime, speed = 1, totalRange) {
|
|
76
|
+
this.stop();
|
|
59
77
|
this._speed = speed;
|
|
78
|
+
this._totalRange = totalRange || { from: fromTime, to: new Date(fromTime.getTime() + 3600000) };
|
|
79
|
+
// 버퍼 생성 + 초기 로드
|
|
80
|
+
this._buffer = new PlaybackBuffer(this._createFetcher(), this._totalRange);
|
|
81
|
+
await this._buffer.loadInitial(fromTime.getTime());
|
|
82
|
+
this._playHead = fromTime.getTime();
|
|
60
83
|
this._state = 'playing';
|
|
61
|
-
this.
|
|
62
|
-
|
|
63
|
-
subscription Playback($fromTime: String!, $speed: Float!) {
|
|
64
|
-
playback(fromTime: $fromTime, speed: $speed) {
|
|
65
|
-
timestamp
|
|
66
|
-
data
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
`,
|
|
70
|
-
variables: {
|
|
71
|
-
fromTime: fromTime.toISOString(),
|
|
72
|
-
speed
|
|
73
|
-
}
|
|
74
|
-
}, {
|
|
75
|
-
next: ({ data }) => {
|
|
76
|
-
if (!(data === null || data === void 0 ? void 0 : data.playback))
|
|
77
|
-
return;
|
|
78
|
-
const { timestamp, data: snapshotData } = data.playback;
|
|
79
|
-
this._currentTime = timestamp;
|
|
80
|
-
// 스냅샷 데이터를 tag별로 분배
|
|
81
|
-
this._distributeSnapshot(snapshotData);
|
|
82
|
-
this._notifyStatus();
|
|
83
|
-
},
|
|
84
|
-
error: (err) => {
|
|
85
|
-
console.error('[PlaybackProvider] subscription error:', err);
|
|
86
|
-
this._state = 'stopped';
|
|
87
|
-
this._notifyStatus();
|
|
88
|
-
},
|
|
89
|
-
complete: () => {
|
|
90
|
-
this._state = 'stopped';
|
|
91
|
-
this._notifyStatus();
|
|
92
|
-
}
|
|
93
|
-
});
|
|
84
|
+
this._lastFrameTime = performance.now();
|
|
85
|
+
this._startPlayLoop();
|
|
94
86
|
this._notifyStatus();
|
|
95
87
|
}
|
|
96
88
|
/**
|
|
97
89
|
* 일시정지
|
|
98
90
|
*/
|
|
99
|
-
|
|
91
|
+
pause() {
|
|
100
92
|
if (this._state !== 'playing')
|
|
101
93
|
return;
|
|
102
94
|
this._state = 'paused';
|
|
103
|
-
|
|
104
|
-
await this._sendCommand('pause');
|
|
95
|
+
this._stopPlayLoop();
|
|
105
96
|
this._notifyStatus();
|
|
106
97
|
}
|
|
107
98
|
/**
|
|
108
99
|
* 재개
|
|
109
100
|
*/
|
|
110
|
-
|
|
101
|
+
resume() {
|
|
111
102
|
if (this._state !== 'paused')
|
|
112
103
|
return;
|
|
113
104
|
this._state = 'playing';
|
|
114
|
-
|
|
105
|
+
this._lastFrameTime = performance.now();
|
|
106
|
+
this._startPlayLoop();
|
|
115
107
|
this._notifyStatus();
|
|
116
108
|
}
|
|
117
109
|
/**
|
|
118
|
-
*
|
|
110
|
+
* seek — 기존 버퍼 폐기 + 새 위치에서 fresh fetch
|
|
119
111
|
*/
|
|
120
112
|
async seek(toTime) {
|
|
121
|
-
|
|
122
|
-
|
|
113
|
+
if (!this._buffer)
|
|
114
|
+
return;
|
|
115
|
+
const wasPlaying = this._state === 'playing';
|
|
116
|
+
this._stopPlayLoop();
|
|
117
|
+
// 버퍼 전체 폐기 + 새 위치 로드
|
|
118
|
+
await this._buffer.seek(toTime.getTime());
|
|
119
|
+
this._playHead = toTime.getTime();
|
|
120
|
+
this._lastDistributedSnapshot = null;
|
|
121
|
+
// 새 위치의 첫 스냅샷 즉시 배포
|
|
122
|
+
const snap = this._buffer.getSnapshotAt(this._playHead);
|
|
123
|
+
if (snap) {
|
|
124
|
+
this._distributeSnapshot(snap.data);
|
|
125
|
+
this._lastDistributedSnapshot = snap;
|
|
126
|
+
}
|
|
127
|
+
if (wasPlaying) {
|
|
128
|
+
this._state = 'playing';
|
|
129
|
+
this._lastFrameTime = performance.now();
|
|
130
|
+
this._startPlayLoop();
|
|
131
|
+
}
|
|
123
132
|
this._notifyStatus();
|
|
124
133
|
}
|
|
125
134
|
/**
|
|
126
135
|
* 배속 변경
|
|
127
136
|
*/
|
|
128
|
-
|
|
137
|
+
setSpeed(speed) {
|
|
129
138
|
this._speed = speed;
|
|
130
|
-
await this._sendCommand('speed', { speed });
|
|
131
139
|
this._notifyStatus();
|
|
132
140
|
}
|
|
133
141
|
/**
|
|
134
|
-
*
|
|
142
|
+
* 중지
|
|
135
143
|
*/
|
|
136
144
|
stop() {
|
|
137
145
|
var _a;
|
|
138
|
-
|
|
139
|
-
this.
|
|
146
|
+
this._stopPlayLoop();
|
|
147
|
+
(_a = this._buffer) === null || _a === void 0 ? void 0 : _a.clear();
|
|
148
|
+
this._buffer = null;
|
|
140
149
|
this._state = 'stopped';
|
|
150
|
+
this._playHead = 0;
|
|
151
|
+
this._lastDistributedSnapshot = null;
|
|
141
152
|
this._notifyStatus();
|
|
142
153
|
}
|
|
143
154
|
/**
|
|
@@ -145,12 +156,23 @@ export class PlaybackProvider {
|
|
|
145
156
|
*/
|
|
146
157
|
dispose() {
|
|
147
158
|
this.stop();
|
|
159
|
+
this._state = 'idle';
|
|
148
160
|
this._components.clear();
|
|
149
161
|
}
|
|
162
|
+
// --- 재생 루프 ---
|
|
163
|
+
_startPlayLoop() {
|
|
164
|
+
this._rafId = requestAnimationFrame(this._playLoop);
|
|
165
|
+
}
|
|
166
|
+
_stopPlayLoop() {
|
|
167
|
+
if (this._rafId) {
|
|
168
|
+
cancelAnimationFrame(this._rafId);
|
|
169
|
+
this._rafId = 0;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// --- 데이터 배포 ---
|
|
150
173
|
_distributeSnapshot(snapshotData) {
|
|
151
174
|
if (!snapshotData || typeof snapshotData !== 'object')
|
|
152
175
|
return;
|
|
153
|
-
// snapshotData: { tag1: value1, tag2: value2, ... }
|
|
154
176
|
for (const [tag, value] of Object.entries(snapshotData)) {
|
|
155
177
|
const components = this._components.get(tag);
|
|
156
178
|
if (components) {
|
|
@@ -160,31 +182,76 @@ export class PlaybackProvider {
|
|
|
160
182
|
}
|
|
161
183
|
}
|
|
162
184
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
185
|
+
// --- 데이터 fetcher ---
|
|
186
|
+
_createFetcher() {
|
|
187
|
+
return async (fromTime, toTime) => {
|
|
188
|
+
var _a;
|
|
189
|
+
try {
|
|
190
|
+
const { client } = await import('@operato/graphql');
|
|
191
|
+
const response = await client.query({
|
|
192
|
+
query: gql `
|
|
193
|
+
query PlaybackChunk($startTime: String!, $endTime: String!) {
|
|
194
|
+
playback(startTime: $startTime, endTime: $endTime) {
|
|
195
|
+
status
|
|
196
|
+
playback {
|
|
197
|
+
scenarios {
|
|
198
|
+
scenarioName
|
|
199
|
+
snapshots {
|
|
200
|
+
type
|
|
201
|
+
time
|
|
202
|
+
data
|
|
203
|
+
}
|
|
204
|
+
}
|
|
175
205
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
`,
|
|
209
|
+
variables: {
|
|
210
|
+
startTime: fromTime.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ''),
|
|
211
|
+
endTime: toTime.toISOString().replace('T', ' ').replace(/\.\d+Z$/, '')
|
|
212
|
+
},
|
|
213
|
+
fetchPolicy: 'no-cache'
|
|
214
|
+
});
|
|
215
|
+
return this._parsePlaybackResponse((_a = response.data) === null || _a === void 0 ? void 0 : _a.playback);
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
console.error('[PlaybackProvider] chunk fetch error:', e);
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* 백엔드 응답을 PlaybackSnapshot[] 형식으로 변환
|
|
225
|
+
*/
|
|
226
|
+
_parsePlaybackResponse(response) {
|
|
227
|
+
var _a;
|
|
228
|
+
if (!(response === null || response === void 0 ? void 0 : response.status) || !((_a = response === null || response === void 0 ? void 0 : response.playback) === null || _a === void 0 ? void 0 : _a.scenarios))
|
|
229
|
+
return [];
|
|
230
|
+
const snapshotMap = new Map();
|
|
231
|
+
for (const scenario of response.playback.scenarios) {
|
|
232
|
+
for (const record of scenario.snapshots) {
|
|
233
|
+
const time = new Date(record.time.replace(' ', 'T')).getTime();
|
|
234
|
+
const data = typeof record.data === 'string' ? JSON.parse(record.data) : record.data;
|
|
235
|
+
if (!snapshotMap.has(time)) {
|
|
236
|
+
snapshotMap.set(time, {});
|
|
237
|
+
}
|
|
238
|
+
const merged = snapshotMap.get(time);
|
|
239
|
+
// scenarioName을 tag로 사용
|
|
240
|
+
merged[scenario.scenarioName] = data;
|
|
241
|
+
}
|
|
180
242
|
}
|
|
243
|
+
return Array.from(snapshotMap.entries())
|
|
244
|
+
.map(([timestamp, data]) => ({ timestamp, data }))
|
|
245
|
+
.sort((a, b) => a.timestamp - b.timestamp);
|
|
181
246
|
}
|
|
247
|
+
// --- 상태 통보 ---
|
|
182
248
|
_notifyStatus() {
|
|
183
|
-
var _a;
|
|
249
|
+
var _a, _b;
|
|
184
250
|
(_a = this._onStatusChange) === null || _a === void 0 ? void 0 : _a.call(this, {
|
|
185
251
|
state: this._state,
|
|
186
|
-
currentTime: this.
|
|
187
|
-
speed: this._speed
|
|
252
|
+
currentTime: this._playHead ? new Date(this._playHead).toISOString() : '',
|
|
253
|
+
speed: this._speed,
|
|
254
|
+
bufferedRanges: (_b = this._buffer) === null || _b === void 0 ? void 0 : _b.getBufferedRanges()
|
|
188
255
|
});
|
|
189
256
|
}
|
|
190
257
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"playback-subscription.js","sourceRoot":"","sources":["../../../src/graphql/playback-subscription.ts"],"names":[],"mappings":"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","sourcesContent":["import { Component, DataSubscriptionProvider } from '@hatiolab/things-scene'\n\nimport gql from 'graphql-tag'\nimport { subscribe } from '@operato/graphql'\n\nexport type PlaybackState = 'idle' | 'playing' | 'paused' | 'stopped'\n\nexport interface PlaybackStatus {\n state: PlaybackState\n currentTime: string\n speed: number\n}\n\nexport interface PlaybackConfig {\n /** 플레이백 가능한 시간 범위 */\n timeRange?: { from: Date; to: Date }\n}\n\n/**\n * PlaybackProvider\n *\n * 백엔드 플레이백 서비스와 point-to-point 구독으로 통신하는 DataSubscriptionProvider.\n * 백엔드가 요청된 시점/배속으로 스냅샷을 실시간처럼 푸시하면,\n * 이를 받아서 등록된 컴포넌트에 전달한다.\n *\n * 실시간 DataSubscriptionProviderImpl과 동일한 인터페이스를 구현하므로\n * Scene 입장에서는 실시간/플레이백을 구분하지 않는다.\n */\nexport class PlaybackProvider implements DataSubscriptionProvider {\n private _components: Map<string, Set<Component>> = new Map()\n private _subscription: { unsubscribe: () => void } | null = null\n private _state: PlaybackState = 'idle'\n private _speed: number = 1\n private _currentTime: string = ''\n private _onStatusChange?: (status: PlaybackStatus) => void\n\n constructor(onStatusChange?: (status: PlaybackStatus) => void) {\n this._onStatusChange = onStatusChange\n }\n\n get state() {\n return this._state\n }\n\n get speed() {\n return this._speed\n }\n\n get currentTime() {\n return this._currentTime\n }\n\n /**\n * DataSubscriptionProvider.subscribe 구현\n * 컴포넌트를 tag별로 등록한다. 실제 데이터는 백엔드 구독을 통해 수신된다.\n */\n async subscribe(tag: string, component: Component) {\n if (!this._components.has(tag)) {\n this._components.set(tag, new Set())\n }\n this._components.get(tag)!.add(component)\n\n return {\n unsubscribe: () => {\n const components = this._components.get(tag)\n if (components) {\n components.delete(component)\n if (components.size === 0) {\n this._components.delete(tag)\n }\n }\n }\n }\n }\n\n /**\n * 플레이백 시작 — 백엔드에 point-to-point 구독을 개시한다.\n */\n async start(fromTime: Date, speed: number = 1) {\n // 기존 구독이 있으면 정리\n this._subscription?.unsubscribe()\n\n this._speed = speed\n this._state = 'playing'\n\n this._subscription = await subscribe(\n {\n query: gql`\n subscription Playback($fromTime: String!, $speed: Float!) {\n playback(fromTime: $fromTime, speed: $speed) {\n timestamp\n data\n }\n }\n `,\n variables: {\n fromTime: fromTime.toISOString(),\n speed\n }\n },\n {\n next: ({ data }: { data: any }) => {\n if (!data?.playback) return\n\n const { timestamp, data: snapshotData } = data.playback\n this._currentTime = timestamp\n\n // 스냅샷 데이터를 tag별로 분배\n this._distributeSnapshot(snapshotData)\n\n this._notifyStatus()\n },\n error: (err: any) => {\n console.error('[PlaybackProvider] subscription error:', err)\n this._state = 'stopped'\n this._notifyStatus()\n },\n complete: () => {\n this._state = 'stopped'\n this._notifyStatus()\n }\n }\n )\n\n this._notifyStatus()\n }\n\n /**\n * 일시정지\n */\n async pause() {\n if (this._state !== 'playing') return\n\n this._state = 'paused'\n\n // 백엔드에 pause 명령 전송 (mutation)\n await this._sendCommand('pause')\n this._notifyStatus()\n }\n\n /**\n * 재개\n */\n async resume() {\n if (this._state !== 'paused') return\n\n this._state = 'playing'\n\n await this._sendCommand('resume')\n this._notifyStatus()\n }\n\n /**\n * 특정 시점으로 이동\n */\n async seek(toTime: Date) {\n await this._sendCommand('seek', { toTime: toTime.toISOString() })\n this._currentTime = toTime.toISOString()\n this._notifyStatus()\n }\n\n /**\n * 배속 변경\n */\n async setSpeed(speed: number) {\n this._speed = speed\n await this._sendCommand('speed', { speed })\n this._notifyStatus()\n }\n\n /**\n * 플레이백 중지 및 구독 해제\n */\n stop() {\n this._subscription?.unsubscribe()\n this._subscription = null\n this._state = 'stopped'\n this._notifyStatus()\n }\n\n /**\n * DataSubscriptionProvider.dispose 구현\n */\n dispose() {\n this.stop()\n this._components.clear()\n }\n\n private _distributeSnapshot(snapshotData: any) {\n if (!snapshotData || typeof snapshotData !== 'object') return\n\n // snapshotData: { tag1: value1, tag2: value2, ... }\n for (const [tag, value] of Object.entries(snapshotData)) {\n const components = this._components.get(tag)\n if (components) {\n for (const component of components) {\n component.data = value\n }\n }\n }\n }\n\n private async _sendCommand(command: string, params: Record<string, any> = {}) {\n try {\n const { client } = await import('@operato/graphql')\n await client.mutate({\n mutation: gql`\n mutation PlaybackControl($command: String!, $params: String) {\n playbackControl(command: $command, params: $params)\n }\n `,\n variables: {\n command,\n params: JSON.stringify(params)\n }\n })\n } catch (e) {\n console.error('[PlaybackProvider] command failed:', command, e)\n }\n }\n\n private _notifyStatus() {\n this._onStatusChange?.({\n state: this._state,\n currentTime: this._currentTime,\n speed: this._speed\n })\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"playback-subscription.js","sourceRoot":"","sources":["../../../src/graphql/playback-subscription.ts"],"names":[],"mappings":"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","sourcesContent":["import { Component, DataSubscriptionProvider } from '@hatiolab/things-scene'\nimport gql from 'graphql-tag'\nimport { PlaybackBuffer, type PlaybackSnapshot, type ChunkFetcher } from './playback-buffer.js'\n\nexport type PlaybackState = 'idle' | 'playing' | 'paused' | 'stopped'\n\nexport interface PlaybackStatus {\n state: PlaybackState\n currentTime: string\n speed: number\n bufferedRanges?: { from: number; to: number }[]\n}\n\nexport interface PlaybackConfig {\n /** 플레이백 가능한 시간 범위 */\n timeRange?: { from: Date; to: Date }\n}\n\n/**\n * PlaybackProvider — YouTube 스트리밍 방식의 청크 기반 플레이백\n *\n * - 10분 단위로 데이터를 fetch하여 버퍼에 적재\n * - 남은 데이터 1분 이하 시 다음 10분 prefetch\n * - seek 시 기존 버퍼 전체 폐기 후 fresh fetch\n * - requestAnimationFrame 기반 재생 루프\n */\nexport class PlaybackProvider implements DataSubscriptionProvider {\n private _components: Map<string, Set<Component>> = new Map()\n private _buffer: PlaybackBuffer | null = null\n private _state: PlaybackState = 'idle'\n private _speed: number = 1\n private _playHead: number = 0\n private _lastFrameTime: number = 0\n private _rafId: number = 0\n private _lastDistributedSnapshot: PlaybackSnapshot | null = null\n private _onStatusChange?: (status: PlaybackStatus) => void\n private _totalRange: { from: Date; to: Date } | null = null\n\n constructor(onStatusChange?: (status: PlaybackStatus) => void) {\n this._onStatusChange = onStatusChange\n }\n\n get state() { return this._state }\n get speed() { return this._speed }\n get currentTime() { return this._playHead ? new Date(this._playHead).toISOString() : '' }\n\n /**\n * DataSubscriptionProvider.subscribe 구현\n */\n async subscribe(tag: string, component: Component) {\n if (!this._components.has(tag)) {\n this._components.set(tag, new Set())\n }\n this._components.get(tag)!.add(component)\n\n return {\n unsubscribe: () => {\n const components = this._components.get(tag)\n if (components) {\n components.delete(component)\n if (components.size === 0) {\n this._components.delete(tag)\n }\n }\n }\n }\n }\n\n /**\n * 플레이백 시작\n */\n async start(fromTime: Date, speed: number = 1, totalRange?: { from: Date; to: Date }) {\n this.stop()\n\n this._speed = speed\n this._totalRange = totalRange || { from: fromTime, to: new Date(fromTime.getTime() + 3600000) }\n\n // 버퍼 생성 + 초기 로드\n this._buffer = new PlaybackBuffer(this._createFetcher(), this._totalRange)\n await this._buffer.loadInitial(fromTime.getTime())\n\n this._playHead = fromTime.getTime()\n this._state = 'playing'\n this._lastFrameTime = performance.now()\n\n this._startPlayLoop()\n this._notifyStatus()\n }\n\n /**\n * 일시정지\n */\n pause() {\n if (this._state !== 'playing') return\n this._state = 'paused'\n this._stopPlayLoop()\n this._notifyStatus()\n }\n\n /**\n * 재개\n */\n resume() {\n if (this._state !== 'paused') return\n this._state = 'playing'\n this._lastFrameTime = performance.now()\n this._startPlayLoop()\n this._notifyStatus()\n }\n\n /**\n * seek — 기존 버퍼 폐기 + 새 위치에서 fresh fetch\n */\n async seek(toTime: Date) {\n if (!this._buffer) return\n\n const wasPlaying = this._state === 'playing'\n this._stopPlayLoop()\n\n // 버퍼 전체 폐기 + 새 위치 로드\n await this._buffer.seek(toTime.getTime())\n this._playHead = toTime.getTime()\n this._lastDistributedSnapshot = null\n\n // 새 위치의 첫 스냅샷 즉시 배포\n const snap = this._buffer.getSnapshotAt(this._playHead)\n if (snap) {\n this._distributeSnapshot(snap.data)\n this._lastDistributedSnapshot = snap\n }\n\n if (wasPlaying) {\n this._state = 'playing'\n this._lastFrameTime = performance.now()\n this._startPlayLoop()\n }\n\n this._notifyStatus()\n }\n\n /**\n * 배속 변경\n */\n setSpeed(speed: number) {\n this._speed = speed\n this._notifyStatus()\n }\n\n /**\n * 중지\n */\n stop() {\n this._stopPlayLoop()\n this._buffer?.clear()\n this._buffer = null\n this._state = 'stopped'\n this._playHead = 0\n this._lastDistributedSnapshot = null\n this._notifyStatus()\n }\n\n /**\n * DataSubscriptionProvider.dispose 구현\n */\n dispose() {\n this.stop()\n this._state = 'idle'\n this._components.clear()\n }\n\n // --- 재생 루프 ---\n\n private _startPlayLoop() {\n this._rafId = requestAnimationFrame(this._playLoop)\n }\n\n private _stopPlayLoop() {\n if (this._rafId) {\n cancelAnimationFrame(this._rafId)\n this._rafId = 0\n }\n }\n\n private _playLoop = (now: number) => {\n if (this._state !== 'playing' || !this._buffer) return\n\n const delta = (now - this._lastFrameTime) * this._speed\n this._lastFrameTime = now\n this._playHead += delta\n\n // 전체 범위 끝 도달 체크\n if (this._totalRange && this._playHead >= this._totalRange.to.getTime()) {\n this._playHead = this._totalRange.to.getTime()\n this._state = 'stopped'\n this._notifyStatus()\n return\n }\n\n // 현재 스냅샷 배포\n const snap = this._buffer.getSnapshotAt(this._playHead)\n if (snap && snap !== this._lastDistributedSnapshot) {\n this._distributeSnapshot(snap.data)\n this._lastDistributedSnapshot = snap\n }\n\n // prefetch 체크 (비동기, 루프 블로킹 안 함)\n this._buffer.checkPrefetch(this._playHead)\n\n this._notifyStatus()\n\n // 다음 프레임\n this._rafId = requestAnimationFrame(this._playLoop)\n }\n\n // --- 데이터 배포 ---\n\n private _distributeSnapshot(snapshotData: any) {\n if (!snapshotData || typeof snapshotData !== 'object') return\n\n for (const [tag, value] of Object.entries(snapshotData)) {\n const components = this._components.get(tag)\n if (components) {\n for (const component of components) {\n component.data = value\n }\n }\n }\n }\n\n // --- 데이터 fetcher ---\n\n private _createFetcher(): ChunkFetcher {\n return async (fromTime: Date, toTime: Date): Promise<PlaybackSnapshot[]> => {\n try {\n const { client } = await import('@operato/graphql')\n const response = await client.query({\n query: gql`\n query PlaybackChunk($startTime: String!, $endTime: String!) {\n playback(startTime: $startTime, endTime: $endTime) {\n status\n playback {\n scenarios {\n scenarioName\n snapshots {\n type\n time\n data\n }\n }\n }\n }\n }\n `,\n variables: {\n startTime: fromTime.toISOString().replace('T', ' ').replace(/\\.\\d+Z$/, ''),\n endTime: toTime.toISOString().replace('T', ' ').replace(/\\.\\d+Z$/, '')\n },\n fetchPolicy: 'no-cache'\n })\n\n return this._parsePlaybackResponse(response.data?.playback)\n } catch (e) {\n console.error('[PlaybackProvider] chunk fetch error:', e)\n return []\n }\n }\n }\n\n /**\n * 백엔드 응답을 PlaybackSnapshot[] 형식으로 변환\n */\n private _parsePlaybackResponse(response: any): PlaybackSnapshot[] {\n if (!response?.status || !response?.playback?.scenarios) return []\n\n const snapshotMap = new Map<number, Record<string, any>>()\n\n for (const scenario of response.playback.scenarios) {\n for (const record of scenario.snapshots) {\n const time = new Date(record.time.replace(' ', 'T')).getTime()\n const data = typeof record.data === 'string' ? JSON.parse(record.data) : record.data\n\n if (!snapshotMap.has(time)) {\n snapshotMap.set(time, {})\n }\n const merged = snapshotMap.get(time)!\n // scenarioName을 tag로 사용\n merged[scenario.scenarioName] = data\n }\n }\n\n return Array.from(snapshotMap.entries())\n .map(([timestamp, data]) => ({ timestamp, data }))\n .sort((a, b) => a.timestamp - b.timestamp)\n }\n\n // --- 상태 통보 ---\n\n private _notifyStatus() {\n this._onStatusChange?.({\n state: this._state,\n currentTime: this._playHead ? new Date(this._playHead).toISOString() : '',\n speed: this._speed,\n bufferedRanges: this._buffer?.getBufferedRanges()\n })\n }\n}\n"]}
|
|
@@ -20,6 +20,10 @@ export declare class PlaybackControls extends LitElement {
|
|
|
20
20
|
playbackState: PlaybackState;
|
|
21
21
|
speed: number;
|
|
22
22
|
currentTime: string;
|
|
23
|
+
bufferedRanges: {
|
|
24
|
+
from: number;
|
|
25
|
+
to: number;
|
|
26
|
+
}[];
|
|
23
27
|
/** 외부에서 전달받은 초기 시간 범위 (from만 사용, to는 from+1h로 자동 계산) */
|
|
24
28
|
timeRange: {
|
|
25
29
|
from: Date;
|
|
@@ -27,6 +31,8 @@ export declare class PlaybackControls extends LitElement {
|
|
|
27
31
|
};
|
|
28
32
|
private _startTime;
|
|
29
33
|
private _seekValue;
|
|
34
|
+
private _seeking;
|
|
35
|
+
private _seekPreviewTime;
|
|
30
36
|
private _speeds;
|
|
31
37
|
private get _endTime();
|
|
32
38
|
private static _floorToHour;
|
|
@@ -39,10 +45,12 @@ export declare class PlaybackControls extends LitElement {
|
|
|
39
45
|
private _onDateChange;
|
|
40
46
|
private _onHourChange;
|
|
41
47
|
private _shiftTime;
|
|
48
|
+
private _shiftStartTime;
|
|
42
49
|
private _onStart;
|
|
43
50
|
private _onTogglePlayPause;
|
|
44
51
|
private _onStop;
|
|
45
52
|
private _onSeekInput;
|
|
46
53
|
private _onSeekChange;
|
|
54
|
+
private _renderBufferBars;
|
|
47
55
|
private _onSpeedChange;
|
|
48
56
|
}
|