@meframe/core 0.1.6 → 0.1.8
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/dist/cache/CacheManager.d.ts +12 -0
- package/dist/cache/CacheManager.d.ts.map +1 -1
- package/dist/cache/CacheManager.js +14 -2
- package/dist/cache/CacheManager.js.map +1 -1
- package/dist/cache/l1/AudioL1Cache.d.ts +15 -0
- package/dist/cache/l1/AudioL1Cache.d.ts.map +1 -1
- package/dist/cache/l1/AudioL1Cache.js +38 -8
- package/dist/cache/l1/AudioL1Cache.js.map +1 -1
- package/dist/controllers/PlaybackController.d.ts +18 -28
- package/dist/controllers/PlaybackController.d.ts.map +1 -1
- package/dist/controllers/PlaybackController.js +343 -253
- package/dist/controllers/PlaybackController.js.map +1 -1
- package/dist/controllers/PlaybackStateMachine.d.ts +16 -0
- package/dist/controllers/PlaybackStateMachine.d.ts.map +1 -0
- package/dist/controllers/PlaybackStateMachine.js +313 -0
- package/dist/controllers/PlaybackStateMachine.js.map +1 -0
- package/dist/controllers/index.d.ts +2 -1
- package/dist/controllers/index.d.ts.map +1 -1
- package/dist/controllers/types.d.ts +172 -2
- package/dist/controllers/types.d.ts.map +1 -1
- package/dist/controllers/types.js +54 -0
- package/dist/controllers/types.js.map +1 -0
- package/dist/model/types.d.ts +0 -1
- package/dist/model/types.d.ts.map +1 -1
- package/dist/model/types.js.map +1 -1
- package/dist/orchestrator/ExportScheduler.d.ts.map +1 -1
- package/dist/orchestrator/ExportScheduler.js +22 -19
- package/dist/orchestrator/ExportScheduler.js.map +1 -1
- package/dist/orchestrator/GlobalAudioSession.d.ts +18 -2
- package/dist/orchestrator/GlobalAudioSession.d.ts.map +1 -1
- package/dist/orchestrator/GlobalAudioSession.js +79 -13
- package/dist/orchestrator/GlobalAudioSession.js.map +1 -1
- package/dist/orchestrator/Orchestrator.d.ts.map +1 -1
- package/dist/orchestrator/Orchestrator.js +8 -3
- package/dist/orchestrator/Orchestrator.js.map +1 -1
- package/dist/orchestrator/VideoClipSession.d.ts.map +1 -1
- package/dist/orchestrator/VideoClipSession.js +4 -2
- package/dist/orchestrator/VideoClipSession.js.map +1 -1
- package/dist/orchestrator/types.d.ts +7 -1
- package/dist/orchestrator/types.d.ts.map +1 -1
- package/dist/stages/load/ResourceLoader.d.ts.map +1 -1
- package/dist/stages/load/ResourceLoader.js +13 -6
- package/dist/stages/load/ResourceLoader.js.map +1 -1
- package/dist/utils/timeout-utils.d.ts +9 -0
- package/dist/utils/timeout-utils.d.ts.map +1 -0
- package/dist/worker/BaseWorker.d.ts +2 -0
- package/dist/worker/BaseWorker.d.ts.map +1 -1
- package/dist/worker/BaseWorker.js.map +1 -1
- package/dist/worker/WorkerChannel.d.ts +2 -0
- package/dist/worker/WorkerChannel.d.ts.map +1 -1
- package/dist/worker/WorkerChannel.js +17 -1
- package/dist/worker/WorkerChannel.js.map +1 -1
- package/dist/workers/{WorkerChannel.DjBEVvEA.js → WorkerChannel.DQK8rAab.js} +18 -2
- package/dist/workers/{WorkerChannel.DjBEVvEA.js.map → WorkerChannel.DQK8rAab.js.map} +1 -1
- package/dist/workers/stages/compose/{audio-compose.worker.CiM_KP27.js → audio-compose.worker.B4Io5w9i.js} +2 -2
- package/dist/workers/stages/compose/{audio-compose.worker.CiM_KP27.js.map → audio-compose.worker.B4Io5w9i.js.map} +1 -1
- package/dist/workers/stages/compose/{video-compose.worker.CQwmNfXT.js → video-compose.worker.CA2_Kpg-.js} +2 -2
- package/dist/workers/stages/compose/{video-compose.worker.CQwmNfXT.js.map → video-compose.worker.CA2_Kpg-.js.map} +1 -1
- package/dist/workers/stages/decode/{audio-decode.worker.CpjkrZtT.js → audio-decode.worker.-DGlQrJD.js} +2 -2
- package/dist/workers/stages/decode/{audio-decode.worker.CpjkrZtT.js.map → audio-decode.worker.-DGlQrJD.js.map} +1 -1
- package/dist/workers/stages/decode/{video-decode.worker.BQtw6eWn.js → video-decode.worker.BnWVUkng.js} +2 -2
- package/dist/workers/stages/decode/{video-decode.worker.BQtw6eWn.js.map → video-decode.worker.BnWVUkng.js.map} +1 -1
- package/dist/workers/stages/demux/{audio-demux.worker.C4V11GQi.js → audio-demux.worker.D-_LoVqW.js} +2 -2
- package/dist/workers/stages/demux/{audio-demux.worker.C4V11GQi.js.map → audio-demux.worker.D-_LoVqW.js.map} +1 -1
- package/dist/workers/stages/demux/{video-demux.worker.5pJr0Ij-.js → video-demux.worker.BWDrLGni.js} +2 -2
- package/dist/workers/stages/demux/{video-demux.worker.5pJr0Ij-.js.map → video-demux.worker.BWDrLGni.js.map} +1 -1
- package/dist/workers/stages/encode/{video-encode.worker.CX2_3YhQ.js → video-encode.worker.D6aB_rF9.js} +2 -2
- package/dist/workers/stages/encode/{video-encode.worker.CX2_3YhQ.js.map → video-encode.worker.D6aB_rF9.js.map} +1 -1
- package/dist/workers/worker-manifest.json +7 -7
- package/package.json +1 -1
|
@@ -1,42 +1,44 @@
|
|
|
1
|
+
import { PlaybackActionType, PlaybackState, PlaybackCommandType } from "./types.js";
|
|
1
2
|
import { MeframeEvent } from "../event/events.js";
|
|
2
3
|
import { WaiterReplacedError } from "../utils/errors.js";
|
|
3
4
|
import { VideoComposer } from "../stages/compose/VideoComposer.js";
|
|
4
5
|
import { isVideoClip } from "../model/types.js";
|
|
6
|
+
import { PlaybackStateMachine } from "./PlaybackStateMachine.js";
|
|
5
7
|
class PlaybackController {
|
|
6
8
|
orchestrator;
|
|
7
9
|
eventBus;
|
|
8
10
|
canvas;
|
|
9
11
|
videoComposer = null;
|
|
10
|
-
// Playback
|
|
12
|
+
// Playback time (external)
|
|
11
13
|
currentTimeUs = 0;
|
|
12
|
-
state = "idle";
|
|
13
14
|
playbackRate = 1;
|
|
14
15
|
volume = 1;
|
|
15
16
|
loop = false;
|
|
16
|
-
//
|
|
17
|
+
// Time base
|
|
17
18
|
rafId = null;
|
|
18
19
|
startTimeUs = 0;
|
|
19
|
-
//
|
|
20
|
-
// Frame
|
|
20
|
+
// AudioContext timeline origin (microseconds)
|
|
21
|
+
// Frame stats
|
|
21
22
|
frameCount = 0;
|
|
22
23
|
lastFrameTime = 0;
|
|
23
24
|
fps = 0;
|
|
25
|
+
// Audio
|
|
24
26
|
audioContext;
|
|
25
27
|
audioSession;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
lastAudioScheduleTime = 0;
|
|
29
|
+
AUDIO_SCHEDULE_INTERVAL = 1e5;
|
|
30
|
+
// 100ms
|
|
31
|
+
// State machine
|
|
32
|
+
fsm = new PlaybackStateMachine();
|
|
29
33
|
// Unified window management for both video and audio
|
|
30
34
|
windowEnd = 0;
|
|
31
35
|
WINDOW_DURATION = 3e6;
|
|
32
36
|
// 3s decode window
|
|
37
|
+
AUDIO_READY_PROBE_WINDOW = 5e5;
|
|
38
|
+
// 500ms: gate buffering only for near-future audio
|
|
33
39
|
PREHEAT_DISTANCE = 1e6;
|
|
34
40
|
// 1s preheat trigger distance
|
|
35
41
|
preheatInProgress = false;
|
|
36
|
-
// Audio scheduling throttle to reduce CPU overhead
|
|
37
|
-
lastAudioScheduleTime = 0;
|
|
38
|
-
AUDIO_SCHEDULE_INTERVAL = 1e5;
|
|
39
|
-
// 100ms (~3 frames at 30fps)
|
|
40
42
|
constructor(orchestrator, eventBus, options) {
|
|
41
43
|
this.orchestrator = orchestrator;
|
|
42
44
|
this.audioSession = orchestrator.audioSession;
|
|
@@ -62,162 +64,62 @@ class PlaybackController {
|
|
|
62
64
|
if (options.loop !== void 0) {
|
|
63
65
|
this.loop = options.loop;
|
|
64
66
|
}
|
|
67
|
+
this.setupEventListeners();
|
|
65
68
|
if (options.autoStart) {
|
|
66
69
|
this.play();
|
|
67
70
|
}
|
|
68
|
-
this.setupEventListeners();
|
|
69
71
|
}
|
|
70
72
|
async renderCover() {
|
|
71
|
-
await this.renderCurrentFrame(0);
|
|
73
|
+
await this.renderCurrentFrame(0, { mode: "blocking" });
|
|
72
74
|
}
|
|
73
|
-
//
|
|
75
|
+
// ========= Public API =========
|
|
74
76
|
play() {
|
|
75
|
-
|
|
76
|
-
this.lastAudioScheduleTime = 0;
|
|
77
|
-
this.wasPlayingBeforeSeek = true;
|
|
78
|
-
this.startPlayback();
|
|
79
|
-
}
|
|
80
|
-
async startPlayback() {
|
|
81
|
-
const wasIdle = this.state === "idle";
|
|
82
|
-
const seekId = this.currentSeekId;
|
|
83
|
-
this.state = "playing";
|
|
84
|
-
try {
|
|
85
|
-
await this.renderCurrentFrame(this.currentTimeUs);
|
|
86
|
-
if (seekId !== this.currentSeekId || this.state !== "playing") {
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
this.initWindow(this.currentTimeUs);
|
|
90
|
-
await this.audioSession.startPlayback(this.currentTimeUs, this.audioContext);
|
|
91
|
-
this.startTimeUs = this.audioContext.currentTime * 1e6 - this.currentTimeUs / this.playbackRate;
|
|
92
|
-
this.playbackLoop();
|
|
93
|
-
this.eventBus.emit(MeframeEvent.PlaybackPlay);
|
|
94
|
-
} catch (error) {
|
|
95
|
-
console.error("[PlaybackController] Failed to start playback:", error);
|
|
96
|
-
this.state = wasIdle ? "idle" : "paused";
|
|
97
|
-
this.eventBus.emit(MeframeEvent.PlaybackError, error);
|
|
98
|
-
}
|
|
77
|
+
this.dispatch({ type: PlaybackActionType.Play });
|
|
99
78
|
}
|
|
100
79
|
pause() {
|
|
101
|
-
this.
|
|
102
|
-
this.wasPlayingBeforeSeek = false;
|
|
103
|
-
if (this.rafId !== null) {
|
|
104
|
-
cancelAnimationFrame(this.rafId);
|
|
105
|
-
this.rafId = null;
|
|
106
|
-
}
|
|
107
|
-
this.audioSession.stopPlayback();
|
|
108
|
-
this.currentSeekId++;
|
|
109
|
-
this.eventBus.emit(MeframeEvent.PlaybackPause);
|
|
80
|
+
this.dispatch({ type: PlaybackActionType.Pause });
|
|
110
81
|
}
|
|
111
82
|
stop() {
|
|
112
|
-
this.
|
|
113
|
-
this.currentTimeUs = 0;
|
|
114
|
-
this.state = "idle";
|
|
115
|
-
this.wasPlayingBeforeSeek = false;
|
|
116
|
-
this.frameCount = 0;
|
|
117
|
-
this.lastFrameTime = 0;
|
|
118
|
-
this.lastAudioScheduleTime = 0;
|
|
119
|
-
const ctx = this.canvas.getContext("2d");
|
|
120
|
-
if (ctx && "clearRect" in ctx) {
|
|
121
|
-
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
122
|
-
}
|
|
123
|
-
this.audioSession.reset();
|
|
124
|
-
this.audioSession.resetPlaybackStates();
|
|
125
|
-
this.eventBus.emit(MeframeEvent.PlaybackStop);
|
|
83
|
+
this.dispatch({ type: PlaybackActionType.Stop });
|
|
126
84
|
}
|
|
127
85
|
async seek(timeUs) {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
this.audioSession.stopPlayback();
|
|
135
|
-
this.lastAudioScheduleTime = 0;
|
|
136
|
-
const clamped = this.clampTime(timeUs);
|
|
137
|
-
this.currentTimeUs = clamped;
|
|
138
|
-
this.currentSeekId++;
|
|
139
|
-
this.state = "seeking";
|
|
140
|
-
const seekId = this.currentSeekId;
|
|
141
|
-
try {
|
|
142
|
-
const keyframeTimeUs = await this.orchestrator.tryRenderKeyframe(clamped);
|
|
143
|
-
if (keyframeTimeUs !== null) {
|
|
144
|
-
const renderState = await this.orchestrator.getRenderState(clamped, {
|
|
145
|
-
immediate: true,
|
|
146
|
-
relativeTimeUs: keyframeTimeUs
|
|
147
|
-
});
|
|
148
|
-
if (renderState && this.videoComposer) {
|
|
149
|
-
await this.videoComposer.composeFrame({
|
|
150
|
-
timeUs: clamped,
|
|
151
|
-
layers: renderState.layers,
|
|
152
|
-
transition: renderState.transition
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
if (seekId !== this.currentSeekId) {
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
await this.audioSession.ensureAudioForTime(clamped, { immediate: false });
|
|
160
|
-
await this.orchestrator.getFrame(clamped, {
|
|
161
|
-
immediate: false,
|
|
162
|
-
preheat: true
|
|
163
|
-
});
|
|
164
|
-
this.initWindow(clamped);
|
|
165
|
-
if (seekId !== this.currentSeekId) {
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
await this.renderCurrentFrame(clamped);
|
|
169
|
-
if (seekId !== this.currentSeekId) {
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
this.eventBus.emit(MeframeEvent.PlaybackSeek, { timeUs: this.currentTimeUs });
|
|
173
|
-
if (this.wasPlayingBeforeSeek) {
|
|
174
|
-
await this.startPlayback();
|
|
175
|
-
} else {
|
|
176
|
-
this.state = previousState === "idle" ? "idle" : "paused";
|
|
177
|
-
}
|
|
178
|
-
} catch (error) {
|
|
179
|
-
if (seekId !== this.currentSeekId) {
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
console.error("[PlaybackController] Seek error:", error);
|
|
183
|
-
this.eventBus.emit(MeframeEvent.PlaybackError, error);
|
|
184
|
-
this.state = previousState === "idle" ? "idle" : "paused";
|
|
185
|
-
}
|
|
86
|
+
const { done } = this.dispatch({
|
|
87
|
+
type: PlaybackActionType.Seek,
|
|
88
|
+
timeUs,
|
|
89
|
+
durationUs: this.duration
|
|
90
|
+
});
|
|
91
|
+
await done;
|
|
186
92
|
}
|
|
187
|
-
// Playback properties
|
|
188
93
|
setRate(rate) {
|
|
189
94
|
const currentTimeUs = this.currentTimeUs;
|
|
190
95
|
this.playbackRate = rate;
|
|
191
96
|
this.startTimeUs = this.audioContext.currentTime * 1e6 - currentTimeUs / rate;
|
|
192
|
-
this.eventBus.emit(MeframeEvent.PlaybackRateChange, { rate });
|
|
193
97
|
this.audioSession.setPlaybackRate(this.playbackRate);
|
|
98
|
+
this.eventBus.emit(MeframeEvent.PlaybackRateChange, { rate });
|
|
194
99
|
}
|
|
195
100
|
setVolume(volume) {
|
|
196
101
|
this.volume = Math.max(0, Math.min(1, volume));
|
|
197
|
-
this.eventBus.emit(MeframeEvent.PlaybackVolumeChange, { volume: this.volume });
|
|
198
102
|
this.audioSession.setVolume(this.volume);
|
|
103
|
+
this.eventBus.emit(MeframeEvent.PlaybackVolumeChange, { volume: this.volume });
|
|
199
104
|
}
|
|
200
105
|
setMute(muted) {
|
|
201
106
|
if (muted) {
|
|
202
107
|
this.audioSession.stopPlayback();
|
|
203
|
-
|
|
204
|
-
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (this.fsm.snapshot.state === PlaybackState.Playing) {
|
|
111
|
+
void this.audioSession.startPlayback(this.currentTimeUs, this.audioContext);
|
|
205
112
|
}
|
|
206
113
|
}
|
|
207
114
|
setLoop(loop) {
|
|
208
115
|
this.loop = loop;
|
|
209
116
|
}
|
|
210
117
|
get duration() {
|
|
211
|
-
|
|
212
|
-
if (modelDuration !== void 0) {
|
|
213
|
-
return modelDuration;
|
|
214
|
-
}
|
|
215
|
-
return 0;
|
|
118
|
+
return this.orchestrator.compositionModel?.durationUs ?? 0;
|
|
216
119
|
}
|
|
217
120
|
get isPlaying() {
|
|
218
|
-
return this.state ===
|
|
121
|
+
return this.fsm.snapshot.state === PlaybackState.Playing;
|
|
219
122
|
}
|
|
220
|
-
// Resume is just an alias for play
|
|
221
123
|
resume() {
|
|
222
124
|
this.play();
|
|
223
125
|
}
|
|
@@ -227,82 +129,296 @@ class PlaybackController {
|
|
|
227
129
|
off(event, handler) {
|
|
228
130
|
this.eventBus.off(event, handler);
|
|
229
131
|
}
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
132
|
+
// ========= State machine wiring =========
|
|
133
|
+
dispatch(action) {
|
|
134
|
+
const { token, commands } = this.fsm.dispatch(action, { currentTimeUs: this.currentTimeUs });
|
|
135
|
+
const done = this.executeCommands(commands, token);
|
|
136
|
+
return { token, done };
|
|
137
|
+
}
|
|
138
|
+
executeCommands(commands, token) {
|
|
139
|
+
const maybe = this.executeSeq(commands, token, 0);
|
|
140
|
+
return maybe ?? Promise.resolve();
|
|
141
|
+
}
|
|
142
|
+
executeSeq(commands, token, startIndex) {
|
|
143
|
+
for (let i = startIndex; i < commands.length; i++) {
|
|
144
|
+
if (!this.isCurrentToken(token)) return;
|
|
145
|
+
const maybe = this.executeCommand(commands[i], token);
|
|
146
|
+
if (maybe) {
|
|
147
|
+
return maybe.then(() => {
|
|
148
|
+
if (!this.isCurrentToken(token)) return;
|
|
149
|
+
const cont = this.executeSeq(commands, token, i + 1);
|
|
150
|
+
return cont ?? Promise.resolve();
|
|
151
|
+
});
|
|
236
152
|
}
|
|
237
|
-
return;
|
|
238
153
|
}
|
|
239
|
-
|
|
240
|
-
|
|
154
|
+
}
|
|
155
|
+
executePar(commands, token) {
|
|
156
|
+
const promises = [];
|
|
157
|
+
for (const c of commands) {
|
|
158
|
+
if (!this.isCurrentToken(token)) return;
|
|
159
|
+
const maybe = this.executeCommand(c, token);
|
|
160
|
+
if (maybe) promises.push(maybe);
|
|
161
|
+
}
|
|
162
|
+
if (promises.length === 0) return;
|
|
163
|
+
return Promise.all(promises).then(() => void 0);
|
|
164
|
+
}
|
|
165
|
+
executeCommand(command, token) {
|
|
166
|
+
if (!this.isCurrentToken(token)) return;
|
|
167
|
+
switch (command.type) {
|
|
168
|
+
case PlaybackCommandType.Seq:
|
|
169
|
+
return this.executeSeq(command.commands, token, 0);
|
|
170
|
+
case PlaybackCommandType.Par:
|
|
171
|
+
return this.executePar(command.commands, token);
|
|
172
|
+
case PlaybackCommandType.Try: {
|
|
173
|
+
const handleError = (error) => {
|
|
174
|
+
if (!this.isCurrentToken(token)) return;
|
|
175
|
+
if (command.ignoreWaiterReplacedError && error instanceof WaiterReplacedError) return;
|
|
176
|
+
if (command.logPrefix) console.error(command.logPrefix, error);
|
|
177
|
+
const onErrorDone = command.onError ? this.dispatch(command.onError).done : void 0;
|
|
178
|
+
const normalizeError = (e) => {
|
|
179
|
+
if (e instanceof Error) return e;
|
|
180
|
+
return new Error(typeof e === "string" ? e : JSON.stringify(e));
|
|
181
|
+
};
|
|
182
|
+
const emit = () => {
|
|
183
|
+
if (command.emitPlaybackError) {
|
|
184
|
+
const err = normalizeError(error);
|
|
185
|
+
this.eventBus.emit(MeframeEvent.PlaybackError, err);
|
|
186
|
+
this.eventBus.emit(MeframeEvent.Error, {
|
|
187
|
+
source: "playback",
|
|
188
|
+
error: err,
|
|
189
|
+
context: {
|
|
190
|
+
command: command.logPrefix,
|
|
191
|
+
onError: command.onError?.type
|
|
192
|
+
},
|
|
193
|
+
recoverable: false
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
if (onErrorDone) {
|
|
198
|
+
return onErrorDone.then(() => {
|
|
199
|
+
emit();
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
emit();
|
|
203
|
+
};
|
|
204
|
+
try {
|
|
205
|
+
const maybe = this.executeCommand(command.command, token);
|
|
206
|
+
if (maybe) {
|
|
207
|
+
return maybe.catch(handleError);
|
|
208
|
+
}
|
|
209
|
+
return;
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return handleError(error) ?? Promise.resolve();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
case PlaybackCommandType.Dispatch:
|
|
215
|
+
return this.dispatch(command.action).done;
|
|
216
|
+
case PlaybackCommandType.SetTime: {
|
|
217
|
+
this.currentTimeUs = command.timeUs;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
case PlaybackCommandType.SetFrozenTime:
|
|
221
|
+
case PlaybackCommandType.SetWantsPlay:
|
|
222
|
+
case PlaybackCommandType.SetState: {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
case PlaybackCommandType.CancelRaf: {
|
|
226
|
+
this.cancelRaf();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
case PlaybackCommandType.StopAudio: {
|
|
230
|
+
this.audioSession.stopPlayback();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
case PlaybackCommandType.ResetAudioPlaybackStates: {
|
|
234
|
+
this.audioSession.resetPlaybackStates();
|
|
241
235
|
return;
|
|
242
236
|
}
|
|
243
|
-
|
|
244
|
-
|
|
237
|
+
case PlaybackCommandType.ResetAudioSession: {
|
|
238
|
+
this.audioSession.reset();
|
|
245
239
|
return;
|
|
246
240
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
241
|
+
case PlaybackCommandType.ClearCanvas: {
|
|
242
|
+
this.clearCanvas();
|
|
243
|
+
return;
|
|
250
244
|
}
|
|
251
|
-
|
|
252
|
-
|
|
245
|
+
case PlaybackCommandType.SetLastAudioScheduleTime: {
|
|
246
|
+
this.lastAudioScheduleTime = command.timeUs;
|
|
253
247
|
return;
|
|
254
248
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const instantFps = 1e3 / deltaTime;
|
|
259
|
-
this.fps = this.fps > 0 ? this.fps * 0.9 + instantFps * 0.1 : instantFps;
|
|
249
|
+
case PlaybackCommandType.SetStartTimeBase: {
|
|
250
|
+
this.startTimeUs = command.startTimeUs;
|
|
251
|
+
return;
|
|
260
252
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
253
|
+
case PlaybackCommandType.SyncTimeBaseToAudioClock: {
|
|
254
|
+
this.startTimeUs = this.audioContext.currentTime * 1e6 - command.timeUs / this.playbackRate;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
case PlaybackCommandType.InitWindow: {
|
|
258
|
+
this.initWindow(command.timeUs);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
case PlaybackCommandType.SetCacheWindow: {
|
|
262
|
+
this.orchestrator.cacheManager.setWindow(command.timeUs);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
case PlaybackCommandType.Emit: {
|
|
266
|
+
if (command.payload === void 0) {
|
|
267
|
+
this.eventBus.emit(command.event);
|
|
268
|
+
} else {
|
|
269
|
+
this.eventBus.emit(command.event, command.payload);
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
case PlaybackCommandType.RenderFrame: {
|
|
274
|
+
return this.renderCurrentFrame(command.timeUs, {
|
|
275
|
+
mode: command.mode,
|
|
276
|
+
relativeTimeUs: command.relativeTimeUs
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
case PlaybackCommandType.MaybeRenderKeyframePreview: {
|
|
280
|
+
return this.orchestrator.tryRenderKeyframe(command.timeUs).then((keyframeTimeUs) => {
|
|
281
|
+
if (!this.isCurrentToken(token)) return;
|
|
282
|
+
if (keyframeTimeUs === null) return;
|
|
283
|
+
return this.orchestrator.getRenderState(command.timeUs, {
|
|
284
|
+
mode: "probe",
|
|
285
|
+
relativeTimeUs: keyframeTimeUs
|
|
286
|
+
}).then((keyframeRenderState) => {
|
|
287
|
+
if (!this.isCurrentToken(token)) return;
|
|
288
|
+
if (!keyframeRenderState) return;
|
|
289
|
+
return this.compose(command.timeUs, keyframeRenderState);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
case PlaybackCommandType.EnsureAudio: {
|
|
294
|
+
return this.audioSession.ensureAudioForTime(command.timeUs, {
|
|
295
|
+
mode: command.mode
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
case PlaybackCommandType.GetFrame: {
|
|
299
|
+
return this.orchestrator.getFrame(command.timeUs, {
|
|
300
|
+
mode: command.mode,
|
|
301
|
+
preheat: command.preheat
|
|
302
|
+
}).then((frame) => {
|
|
303
|
+
if (!frame && command.mode === "blocking") {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`[PlaybackController] GetFrame miss in blocking mode at ${command.timeUs}`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
case PlaybackCommandType.StartAudioPlayback: {
|
|
311
|
+
return this.audioSession.startPlayback(command.timeUs, this.audioContext);
|
|
312
|
+
}
|
|
313
|
+
case PlaybackCommandType.ProbeStartReady: {
|
|
314
|
+
const audioWindowEnd = Math.min(
|
|
315
|
+
this.duration,
|
|
316
|
+
command.timeUs + this.AUDIO_READY_PROBE_WINDOW
|
|
317
|
+
);
|
|
318
|
+
const audioReady = this.audioSession.isAudioResourceWindowReady(
|
|
319
|
+
command.timeUs,
|
|
320
|
+
audioWindowEnd
|
|
321
|
+
);
|
|
322
|
+
const videoReady = this.isVideoResourceReadyAtTime(command.timeUs);
|
|
323
|
+
if (audioReady && videoReady) return;
|
|
324
|
+
if (!audioReady) {
|
|
325
|
+
void this.audioSession.ensureAudioForTime(command.timeUs, { mode: "probe" });
|
|
326
|
+
}
|
|
327
|
+
if (!videoReady) {
|
|
328
|
+
void this.orchestrator.getFrame(command.timeUs, { mode: "probe" });
|
|
329
|
+
}
|
|
330
|
+
this.dispatch({
|
|
331
|
+
type: PlaybackActionType.EnterBuffering,
|
|
332
|
+
timeUs: command.timeUs,
|
|
333
|
+
bumpToken: true,
|
|
334
|
+
reason: "startup"
|
|
335
|
+
});
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
case PlaybackCommandType.StartRafLoop: {
|
|
339
|
+
this.startPlaybackLoop(token);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
cancelRaf() {
|
|
345
|
+
if (this.rafId !== null) {
|
|
346
|
+
cancelAnimationFrame(this.rafId);
|
|
347
|
+
this.rafId = null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
isCurrentToken(token) {
|
|
351
|
+
return token === this.fsm.snapshot.token;
|
|
352
|
+
}
|
|
353
|
+
startPlaybackLoop(token) {
|
|
354
|
+
this.rafId = requestAnimationFrame(() => {
|
|
355
|
+
void this.onRafTick(token);
|
|
265
356
|
});
|
|
266
357
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
358
|
+
async onRafTick(token) {
|
|
359
|
+
if (!this.isCurrentToken(token) || this.fsm.snapshot.state !== PlaybackState.Playing) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const candidateTimeUs = (this.audioContext.currentTime * 1e6 - this.startTimeUs) * this.playbackRate;
|
|
363
|
+
this.dispatch({
|
|
364
|
+
type: PlaybackActionType.ClockTick,
|
|
365
|
+
candidateTimeUs,
|
|
366
|
+
durationUs: this.duration,
|
|
367
|
+
loop: this.loop,
|
|
368
|
+
audioNowUs: this.audioContext.currentTime * 1e6
|
|
369
|
+
});
|
|
370
|
+
if (!this.isCurrentToken(token) || this.fsm.snapshot.state !== PlaybackState.Playing) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const audioWindowEnd = Math.min(
|
|
374
|
+
this.duration,
|
|
375
|
+
this.currentTimeUs + this.AUDIO_READY_PROBE_WINDOW
|
|
376
|
+
);
|
|
377
|
+
if (!this.audioSession.isAudioResourceWindowReady(this.currentTimeUs, audioWindowEnd)) {
|
|
378
|
+
void this.audioSession.ensureAudioForTime(this.currentTimeUs, { mode: "probe" });
|
|
379
|
+
this.dispatch({ type: PlaybackActionType.EnterBuffering, timeUs: this.currentTimeUs });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (this.currentTimeUs - this.lastAudioScheduleTime >= this.AUDIO_SCHEDULE_INTERVAL) {
|
|
383
|
+
await this.audioSession.scheduleAudio(this.currentTimeUs, this.audioContext);
|
|
384
|
+
if (!this.isCurrentToken(token) || this.fsm.snapshot.state !== PlaybackState.Playing) return;
|
|
385
|
+
this.lastAudioScheduleTime = this.currentTimeUs;
|
|
386
|
+
}
|
|
387
|
+
const renderState = await this.orchestrator.getRenderState(this.currentTimeUs, {
|
|
388
|
+
mode: "probe"
|
|
389
|
+
});
|
|
390
|
+
if (!this.isCurrentToken(token) || this.fsm.snapshot.state !== PlaybackState.Playing) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (!renderState) {
|
|
394
|
+
this.dispatch({ type: PlaybackActionType.EnterBuffering, timeUs: this.currentTimeUs });
|
|
395
|
+
return;
|
|
283
396
|
}
|
|
284
|
-
this.
|
|
397
|
+
await this.compose(this.currentTimeUs, renderState);
|
|
398
|
+
if (!this.isCurrentToken(token) || this.fsm.snapshot.state !== PlaybackState.Playing) return;
|
|
399
|
+
this.updateFps();
|
|
400
|
+
this.frameCount++;
|
|
401
|
+
this.orchestrator.cacheManager.setWindow(this.currentTimeUs);
|
|
285
402
|
this.checkAndPreheatWindow();
|
|
403
|
+
if (!this.isCurrentToken(token) || this.fsm.snapshot.state !== PlaybackState.Playing) return;
|
|
404
|
+
this.startPlaybackLoop(token);
|
|
405
|
+
}
|
|
406
|
+
updateFps() {
|
|
407
|
+
const now = performance.now();
|
|
408
|
+
if (this.lastFrameTime > 0) {
|
|
409
|
+
const deltaTime = now - this.lastFrameTime;
|
|
410
|
+
const instantFps = 1e3 / deltaTime;
|
|
411
|
+
this.fps = this.fps > 0 ? this.fps * 0.9 + instantFps * 0.1 : instantFps;
|
|
412
|
+
}
|
|
413
|
+
this.lastFrameTime = now;
|
|
286
414
|
}
|
|
287
|
-
/**
|
|
288
|
-
* Initialize window at given time (called on play/seek)
|
|
289
|
-
* Sets unified window for both video and audio
|
|
290
|
-
*/
|
|
291
415
|
initWindow(timeUs) {
|
|
292
416
|
this.windowEnd = timeUs + this.WINDOW_DURATION;
|
|
293
417
|
this.preheatInProgress = false;
|
|
294
418
|
this.orchestrator.cacheManager.setWindow(timeUs);
|
|
295
419
|
}
|
|
296
|
-
/**
|
|
297
|
-
* Check if approaching window end and trigger preheat for next window
|
|
298
|
-
*
|
|
299
|
-
* Strategy: Unified sliding window for both video and audio
|
|
300
|
-
* - Current window: [windowStart, windowEnd] (3s duration)
|
|
301
|
-
* - When playback reaches windowEnd - 1s, preheat next window
|
|
302
|
-
* - Next window: [windowEnd, windowEnd + 3s]
|
|
303
|
-
*/
|
|
304
420
|
checkAndPreheatWindow() {
|
|
305
|
-
if (this.preheatInProgress || this.state !==
|
|
421
|
+
if (this.preheatInProgress || this.fsm.snapshot.state !== PlaybackState.Playing) {
|
|
306
422
|
return;
|
|
307
423
|
}
|
|
308
424
|
const distanceToWindowEnd = this.windowEnd - this.currentTimeUs;
|
|
@@ -311,15 +427,11 @@ class PlaybackController {
|
|
|
311
427
|
return;
|
|
312
428
|
}
|
|
313
429
|
if (distanceToWindowEnd > 0 && distanceToWindowEnd <= this.PREHEAT_DISTANCE) {
|
|
314
|
-
this.preheatNextWindow();
|
|
430
|
+
void this.preheatNextWindow();
|
|
315
431
|
}
|
|
316
432
|
}
|
|
317
|
-
/**
|
|
318
|
-
* Preheat next window by decoding from current playback time
|
|
319
|
-
* Supports cross-clip window preheating for seamless playback
|
|
320
|
-
* Preheats both video and audio in parallel
|
|
321
|
-
*/
|
|
322
433
|
async preheatNextWindow() {
|
|
434
|
+
if (this.preheatInProgress) return;
|
|
323
435
|
this.preheatInProgress = true;
|
|
324
436
|
try {
|
|
325
437
|
const windowStart = this.currentTimeUs;
|
|
@@ -335,7 +447,7 @@ class PlaybackController {
|
|
|
335
447
|
this.orchestrator.preheatClipWindow(clip.id, clipWindowStart, clipWindowEnd, windowStart)
|
|
336
448
|
);
|
|
337
449
|
}
|
|
338
|
-
preheatPromises.push(this.audioSession.ensureAudioForTime(windowStart, {
|
|
450
|
+
preheatPromises.push(this.audioSession.ensureAudioForTime(windowStart, { mode: "blocking" }));
|
|
339
451
|
await Promise.all(preheatPromises);
|
|
340
452
|
this.windowEnd = windowEnd;
|
|
341
453
|
} catch (error) {
|
|
@@ -344,70 +456,38 @@ class PlaybackController {
|
|
|
344
456
|
this.preheatInProgress = false;
|
|
345
457
|
}
|
|
346
458
|
}
|
|
347
|
-
async renderCurrentFrame(timeUs) {
|
|
459
|
+
async renderCurrentFrame(timeUs, options) {
|
|
348
460
|
if (!this.videoComposer) {
|
|
349
461
|
console.error("[PlaybackController] VideoComposer not initialized");
|
|
350
462
|
return;
|
|
351
463
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
return;
|
|
464
|
+
const renderState = await this.orchestrator.getRenderState(timeUs, {
|
|
465
|
+
mode: options.mode,
|
|
466
|
+
relativeTimeUs: options.relativeTimeUs
|
|
467
|
+
});
|
|
468
|
+
if (!renderState) {
|
|
469
|
+
if (options.mode === "blocking") {
|
|
470
|
+
throw new Error(`[PlaybackController] RenderFrame miss in blocking mode at ${timeUs}`);
|
|
361
471
|
}
|
|
362
|
-
await this.videoComposer.composeFrame({
|
|
363
|
-
timeUs,
|
|
364
|
-
layers: renderState.layers,
|
|
365
|
-
transition: renderState.transition
|
|
366
|
-
});
|
|
367
|
-
} catch (error) {
|
|
368
|
-
console.error("Render error:", error);
|
|
369
|
-
this.eventBus.emit(MeframeEvent.PlaybackError, error);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
async handlePlaybackBuffering(timeUs) {
|
|
373
|
-
if (this.state !== "playing") {
|
|
374
472
|
return;
|
|
375
473
|
}
|
|
376
|
-
|
|
377
|
-
this.state = "buffering";
|
|
378
|
-
this.eventBus.emit(MeframeEvent.PlaybackBuffering);
|
|
379
|
-
this.audioSession.stopPlayback();
|
|
380
|
-
try {
|
|
381
|
-
this.orchestrator.cacheManager.setWindow(timeUs);
|
|
382
|
-
await this.orchestrator.getFrame(timeUs, { immediate: false });
|
|
383
|
-
await this.audioSession.ensureAudioForTime(timeUs, { immediate: false });
|
|
384
|
-
if (seekId !== this.currentSeekId || this.state !== "buffering") {
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
this.state = "playing";
|
|
388
|
-
this.startTimeUs = this.audioContext.currentTime * 1e6 - timeUs / this.playbackRate;
|
|
389
|
-
this.lastAudioScheduleTime = 0;
|
|
390
|
-
await this.audioSession.startPlayback(timeUs, this.audioContext);
|
|
391
|
-
this.eventBus.emit(MeframeEvent.PlaybackPlay);
|
|
392
|
-
if (!this.rafId) {
|
|
393
|
-
this.playbackLoop();
|
|
394
|
-
}
|
|
395
|
-
} catch (error) {
|
|
396
|
-
if (error instanceof WaiterReplacedError) {
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
if (seekId !== this.currentSeekId) {
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
console.error("[PlaybackController] Buffering error:", error);
|
|
403
|
-
this.state = "paused";
|
|
404
|
-
this.eventBus.emit(MeframeEvent.PlaybackError, error);
|
|
405
|
-
}
|
|
474
|
+
await this.compose(timeUs, renderState);
|
|
406
475
|
}
|
|
407
|
-
|
|
408
|
-
|
|
476
|
+
async compose(timeUs, renderState) {
|
|
477
|
+
if (!this.videoComposer) return;
|
|
478
|
+
await this.videoComposer.composeFrame({
|
|
479
|
+
timeUs,
|
|
480
|
+
layers: renderState.layers,
|
|
481
|
+
transition: renderState.transition
|
|
482
|
+
});
|
|
409
483
|
}
|
|
410
|
-
|
|
484
|
+
clearCanvas() {
|
|
485
|
+
const ctx = this.canvas.getContext("2d");
|
|
486
|
+
if (ctx && "clearRect" in ctx) {
|
|
487
|
+
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// ========= Cleanup / event handlers =========
|
|
411
491
|
dispose() {
|
|
412
492
|
this.stop();
|
|
413
493
|
this.eventBus.off(MeframeEvent.CacheCover, this.onCacheCover);
|
|
@@ -418,8 +498,8 @@ class PlaybackController {
|
|
|
418
498
|
}
|
|
419
499
|
}
|
|
420
500
|
onCacheCover = () => {
|
|
421
|
-
if (this.state ===
|
|
422
|
-
this.renderCurrentFrame(0);
|
|
501
|
+
if (this.fsm.snapshot.state === PlaybackState.Idle && this.currentTimeUs === 0) {
|
|
502
|
+
void this.renderCurrentFrame(0, { mode: "blocking" });
|
|
423
503
|
}
|
|
424
504
|
};
|
|
425
505
|
onModelSet = () => {
|
|
@@ -431,13 +511,23 @@ class PlaybackController {
|
|
|
431
511
|
fps: model.fps || 30,
|
|
432
512
|
backgroundColor: model.renderConfig?.backgroundColor || "#000"
|
|
433
513
|
});
|
|
434
|
-
this.audioSession.ensureAudioForTime(this.currentTimeUs, {
|
|
435
|
-
this.renderCurrentFrame(this.currentTimeUs);
|
|
514
|
+
void this.audioSession.ensureAudioForTime(this.currentTimeUs, { mode: "blocking" });
|
|
515
|
+
void this.renderCurrentFrame(this.currentTimeUs, { mode: "blocking" });
|
|
436
516
|
};
|
|
437
517
|
setupEventListeners() {
|
|
438
518
|
this.eventBus.on(MeframeEvent.CacheCover, this.onCacheCover);
|
|
439
519
|
this.eventBus.on(MeframeEvent.ModelSet, this.onModelSet);
|
|
440
520
|
}
|
|
521
|
+
isVideoResourceReadyAtTime(timeUs) {
|
|
522
|
+
const model = this.orchestrator.compositionModel;
|
|
523
|
+
if (!model) return true;
|
|
524
|
+
const clip = model.getClipsAtTime(timeUs, model.mainTrackId)[0];
|
|
525
|
+
if (!clip || !("resourceId" in clip) || typeof clip.resourceId !== "string")
|
|
526
|
+
return true;
|
|
527
|
+
const resourceId = clip.resourceId;
|
|
528
|
+
const resource = model.getResource(resourceId);
|
|
529
|
+
return resource?.state === "ready";
|
|
530
|
+
}
|
|
441
531
|
}
|
|
442
532
|
export {
|
|
443
533
|
PlaybackController
|