@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.
Files changed (70) hide show
  1. package/dist/cache/CacheManager.d.ts +12 -0
  2. package/dist/cache/CacheManager.d.ts.map +1 -1
  3. package/dist/cache/CacheManager.js +14 -2
  4. package/dist/cache/CacheManager.js.map +1 -1
  5. package/dist/cache/l1/AudioL1Cache.d.ts +15 -0
  6. package/dist/cache/l1/AudioL1Cache.d.ts.map +1 -1
  7. package/dist/cache/l1/AudioL1Cache.js +38 -8
  8. package/dist/cache/l1/AudioL1Cache.js.map +1 -1
  9. package/dist/controllers/PlaybackController.d.ts +18 -28
  10. package/dist/controllers/PlaybackController.d.ts.map +1 -1
  11. package/dist/controllers/PlaybackController.js +343 -253
  12. package/dist/controllers/PlaybackController.js.map +1 -1
  13. package/dist/controllers/PlaybackStateMachine.d.ts +16 -0
  14. package/dist/controllers/PlaybackStateMachine.d.ts.map +1 -0
  15. package/dist/controllers/PlaybackStateMachine.js +313 -0
  16. package/dist/controllers/PlaybackStateMachine.js.map +1 -0
  17. package/dist/controllers/index.d.ts +2 -1
  18. package/dist/controllers/index.d.ts.map +1 -1
  19. package/dist/controllers/types.d.ts +172 -2
  20. package/dist/controllers/types.d.ts.map +1 -1
  21. package/dist/controllers/types.js +54 -0
  22. package/dist/controllers/types.js.map +1 -0
  23. package/dist/model/types.d.ts +0 -1
  24. package/dist/model/types.d.ts.map +1 -1
  25. package/dist/model/types.js.map +1 -1
  26. package/dist/orchestrator/ExportScheduler.d.ts.map +1 -1
  27. package/dist/orchestrator/ExportScheduler.js +22 -19
  28. package/dist/orchestrator/ExportScheduler.js.map +1 -1
  29. package/dist/orchestrator/GlobalAudioSession.d.ts +18 -2
  30. package/dist/orchestrator/GlobalAudioSession.d.ts.map +1 -1
  31. package/dist/orchestrator/GlobalAudioSession.js +79 -13
  32. package/dist/orchestrator/GlobalAudioSession.js.map +1 -1
  33. package/dist/orchestrator/Orchestrator.d.ts.map +1 -1
  34. package/dist/orchestrator/Orchestrator.js +8 -3
  35. package/dist/orchestrator/Orchestrator.js.map +1 -1
  36. package/dist/orchestrator/VideoClipSession.d.ts.map +1 -1
  37. package/dist/orchestrator/VideoClipSession.js +4 -2
  38. package/dist/orchestrator/VideoClipSession.js.map +1 -1
  39. package/dist/orchestrator/types.d.ts +7 -1
  40. package/dist/orchestrator/types.d.ts.map +1 -1
  41. package/dist/stages/load/ResourceLoader.d.ts.map +1 -1
  42. package/dist/stages/load/ResourceLoader.js +13 -6
  43. package/dist/stages/load/ResourceLoader.js.map +1 -1
  44. package/dist/utils/timeout-utils.d.ts +9 -0
  45. package/dist/utils/timeout-utils.d.ts.map +1 -0
  46. package/dist/worker/BaseWorker.d.ts +2 -0
  47. package/dist/worker/BaseWorker.d.ts.map +1 -1
  48. package/dist/worker/BaseWorker.js.map +1 -1
  49. package/dist/worker/WorkerChannel.d.ts +2 -0
  50. package/dist/worker/WorkerChannel.d.ts.map +1 -1
  51. package/dist/worker/WorkerChannel.js +17 -1
  52. package/dist/worker/WorkerChannel.js.map +1 -1
  53. package/dist/workers/{WorkerChannel.DjBEVvEA.js → WorkerChannel.DQK8rAab.js} +18 -2
  54. package/dist/workers/{WorkerChannel.DjBEVvEA.js.map → WorkerChannel.DQK8rAab.js.map} +1 -1
  55. package/dist/workers/stages/compose/{audio-compose.worker.CiM_KP27.js → audio-compose.worker.B4Io5w9i.js} +2 -2
  56. package/dist/workers/stages/compose/{audio-compose.worker.CiM_KP27.js.map → audio-compose.worker.B4Io5w9i.js.map} +1 -1
  57. package/dist/workers/stages/compose/{video-compose.worker.CQwmNfXT.js → video-compose.worker.CA2_Kpg-.js} +2 -2
  58. package/dist/workers/stages/compose/{video-compose.worker.CQwmNfXT.js.map → video-compose.worker.CA2_Kpg-.js.map} +1 -1
  59. package/dist/workers/stages/decode/{audio-decode.worker.CpjkrZtT.js → audio-decode.worker.-DGlQrJD.js} +2 -2
  60. package/dist/workers/stages/decode/{audio-decode.worker.CpjkrZtT.js.map → audio-decode.worker.-DGlQrJD.js.map} +1 -1
  61. package/dist/workers/stages/decode/{video-decode.worker.BQtw6eWn.js → video-decode.worker.BnWVUkng.js} +2 -2
  62. package/dist/workers/stages/decode/{video-decode.worker.BQtw6eWn.js.map → video-decode.worker.BnWVUkng.js.map} +1 -1
  63. package/dist/workers/stages/demux/{audio-demux.worker.C4V11GQi.js → audio-demux.worker.D-_LoVqW.js} +2 -2
  64. package/dist/workers/stages/demux/{audio-demux.worker.C4V11GQi.js.map → audio-demux.worker.D-_LoVqW.js.map} +1 -1
  65. package/dist/workers/stages/demux/{video-demux.worker.5pJr0Ij-.js → video-demux.worker.BWDrLGni.js} +2 -2
  66. package/dist/workers/stages/demux/{video-demux.worker.5pJr0Ij-.js.map → video-demux.worker.BWDrLGni.js.map} +1 -1
  67. package/dist/workers/stages/encode/{video-encode.worker.CX2_3YhQ.js → video-encode.worker.D6aB_rF9.js} +2 -2
  68. package/dist/workers/stages/encode/{video-encode.worker.CX2_3YhQ.js.map → video-encode.worker.D6aB_rF9.js.map} +1 -1
  69. package/dist/workers/worker-manifest.json +7 -7
  70. 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 state
12
+ // Playback time (external)
11
13
  currentTimeUs = 0;
12
- state = "idle";
13
14
  playbackRate = 1;
14
15
  volume = 1;
15
16
  loop = false;
16
- // Animation loop
17
+ // Time base
17
18
  rafId = null;
18
19
  startTimeUs = 0;
19
- // Playback start position in AudioContext timeline (microseconds)
20
- // Frame tracking
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
- // Seek tracking
27
- currentSeekId = 0;
28
- wasPlayingBeforeSeek = false;
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
- // Playback control
75
+ // ========= Public API =========
74
76
  play() {
75
- if (this.state === "playing") return;
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.state = "paused";
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.pause();
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 previousState = this.state;
129
- this.orchestrator.cancelActiveDecoding();
130
- if (this.rafId !== null) {
131
- cancelAnimationFrame(this.rafId);
132
- this.rafId = null;
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
- } else if (this.state === "playing") {
204
- this.audioSession.startPlayback(this.currentTimeUs, this.audioContext);
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
- const modelDuration = this.orchestrator.compositionModel?.durationUs;
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 === "playing";
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
- // Private methods
231
- playbackLoop() {
232
- if (this.state !== "playing") {
233
- if (this.rafId !== null) {
234
- cancelAnimationFrame(this.rafId);
235
- this.rafId = null;
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
- this.rafId = requestAnimationFrame(async () => {
240
- if (this.state !== "playing") {
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
- this.updateTime();
244
- if (this.state !== "playing") {
237
+ case PlaybackCommandType.ResetAudioSession: {
238
+ this.audioSession.reset();
245
239
  return;
246
240
  }
247
- if (this.currentTimeUs - this.lastAudioScheduleTime >= this.AUDIO_SCHEDULE_INTERVAL) {
248
- await this.audioSession.scheduleAudio(this.currentTimeUs, this.audioContext);
249
- this.lastAudioScheduleTime = this.currentTimeUs;
241
+ case PlaybackCommandType.ClearCanvas: {
242
+ this.clearCanvas();
243
+ return;
250
244
  }
251
- await this.renderCurrentFrame(this.currentTimeUs);
252
- if (this.state !== "playing") {
245
+ case PlaybackCommandType.SetLastAudioScheduleTime: {
246
+ this.lastAudioScheduleTime = command.timeUs;
253
247
  return;
254
248
  }
255
- const now = performance.now();
256
- if (this.lastFrameTime > 0) {
257
- const deltaTime = now - this.lastFrameTime;
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
- this.lastFrameTime = now;
262
- this.frameCount++;
263
- this.orchestrator.cacheManager.setWindow(this.currentTimeUs);
264
- this.playbackLoop();
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
- updateTime() {
268
- const elapsedUs = (this.audioContext.currentTime * 1e6 - this.startTimeUs) * this.playbackRate;
269
- this.currentTimeUs = elapsedUs;
270
- if (this.currentTimeUs >= this.duration) {
271
- if (this.loop) {
272
- this.currentTimeUs = 0;
273
- this.startTimeUs = this.audioContext.currentTime * 1e6;
274
- this.audioSession.resetPlaybackStates();
275
- this.lastAudioScheduleTime = 0;
276
- this.initWindow(0);
277
- } else {
278
- this.pause();
279
- this.currentTimeUs = 0;
280
- this.state = "ended";
281
- this.eventBus.emit(MeframeEvent.PlaybackEnded, { timeUs: this.duration });
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.eventBus.emit(MeframeEvent.PlaybackTimeUpdate, { timeUs: this.currentTimeUs });
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 !== "playing") {
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, { immediate: false }));
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
- try {
353
- const renderState = await this.orchestrator.getRenderState(timeUs, {
354
- immediate: this.state === "playing"
355
- });
356
- if (!renderState) {
357
- if (this.state === "playing") {
358
- await this.handlePlaybackBuffering(timeUs);
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
- const seekId = this.currentSeekId;
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
- clampTime(timeUs) {
408
- return Math.max(0, Math.min(timeUs, this.duration));
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
- // Cleanup
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 === "idle" && this.currentTimeUs === 0) {
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, { immediate: false });
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