const clamp01 = (value) => Math.max(0, Math.min(1, value)); /** Own playback timers and scene-clock subscriptions independently of renderer and editor state. */ export function createPlaybackClock(options) { return new PlaybackClock(options); } class PlaybackClock { constructor({ isRunning, timingForShot, onProgress, now = () => Date.now(), schedule = (callback, ms) => setInterval(callback, ms), cancel = (timer) => clearInterval(timer), }) { this.isRunning = isRunning; this.timingForShot = timingForShot; this.onProgress = onProgress; this.now = now; this.schedule = schedule; this.cancel = cancel; this._progressTimer = null; this._shotProgressTimer = null; this._sceneClockTimer = null; this._sceneClockListeners = new Set(); this._sceneClockSnapshot = null; this._destroyed = false; this._waitGeneration = 0; this._shotGeneration = 0; this._sceneGeneration = 0; this._waiters = new Set(); } get snapshot() { return this._sceneClockSnapshot ? { ...this._sceneClockSnapshot } : null; } get activeTimers() { return ( [ this._progressTimer, this._shotProgressTimer, this._sceneClockTimer, ].filter((timer) => timer !== null).length + this._waiters.size ); } stopShot() { this._shotGeneration++; this.cancel(this._shotProgressTimer); this._shotProgressTimer = null; } stopSceneTimer() { this._sceneGeneration++; this.cancel(this._sceneClockTimer); this._sceneClockTimer = null; } finish() { this.cancel(this._progressTimer); this._progressTimer = null; this.stopSceneTimer(); } stop() { this.cancelWaits(); this.stopShot(); this.finish(); if ( !this._sceneClockSnapshot || this._sceneClockSnapshot.stopped || this._destroyed ) return; this._sceneClockSnapshot = { ...this._sceneClockSnapshot, running: false, stopped: true, }; for (const listener of this._sceneClockListeners) { try { listener(this.snapshot); } catch { /* Observers cannot block cancellation. */ } } } destroy() { this.cancelWaits(); this._destroyed = true; this.stopShot(); this.finish(); this._sceneClockListeners.clear(); this._sceneClockSnapshot = null; } cancelWaits() { this._waitGeneration++; for (const finish of [...this._waiters]) finish(); } /** Own cancellable hold delays; Stop and destroy settle pending waiters immediately. */ async wait(ms, token) { const generation = this._waitGeneration; const endAt = this.now() + ms; const current = () => !this._destroyed && generation === this._waitGeneration && !token.cancelled && !token.signal?.aborted; while (current() && this.now() < endAt) { await new Promise((resolve) => { const finish = () => { clearTimeout(timer); token.signal?.removeEventListener('abort', finish); this._waiters.delete(finish); resolve(); }; const timer = setTimeout(finish, 70); this._waiters.add(finish); token.signal?.addEventListener('abort', finish, { once: true }); }); } } subscribe(listener) { if (this._destroyed || typeof listener !== 'function') return () => {}; this._sceneClockListeners.add(listener); if (this._sceneClockSnapshot) { try { listener({ ...this._sceneClockSnapshot }); } catch (error) { console.warn('[Scenes] Scene clock listener failed:', error); } } return () => this._sceneClockListeners.delete(listener); } publish( scene, shot, sceneElapsedSec, { running = this.isRunning(), seeking = false } = {}, ) { if (this._destroyed || !scene || !shot) return; const timing = this.timingForShot(scene, shot); const elapsedSec = Math.max( 0, Math.min(timing.totalSec, Number(sceneElapsedSec) || 0), ); this._sceneClockSnapshot = { sceneId: scene.id, shotId: shot.id, shotIndex: timing.shotIndex, shotCount: scene.shots.length, sceneElapsedSec: elapsedSec, sceneDurationSec: timing.totalSec, sceneProgress: timing.totalSec > 0 ? elapsedSec / timing.totalSec : 0, running: Boolean(running), seeking: Boolean(seeking), }; for (const listener of this._sceneClockListeners) { try { listener({ ...this._sceneClockSnapshot }); } catch (error) { console.warn('[Scenes] Scene clock listener failed:', error); } } } startRunProgress(totalSec) { this.cancel(this._progressTimer); if (this._destroyed) return; const startMs = this.now(); const totalMs = Math.max(1000, totalSec * 1000); const timer = this.schedule(() => { if (this._destroyed || this._progressTimer !== timer || !this.isRunning()) return; const elapsedMs = this.now() - startMs; this.onProgress(elapsedMs / totalMs); }, 100); this._progressTimer = timer; } startShotProgress(token, seconds, from, to, sceneClock = null) { this.stopShot(); const generation = this._shotGeneration; if ( generation !== this._shotGeneration || this._destroyed || token.cancelled || token.signal?.aborted || this.isRunning() ) return; this.onProgress(from); if (generation !== this._shotGeneration || this._destroyed) return; if (sceneClock) { this.publish( sceneClock.scene, sceneClock.shot, sceneClock.sceneElapsedFrom, { running: false }, ); } if ( generation !== this._shotGeneration || this._destroyed || token.cancelled || token.signal?.aborted || this.isRunning() ) return; if (seconds <= 0) { this.onProgress(to); if (generation !== this._shotGeneration || this._destroyed) return; if (sceneClock) { this.publish( sceneClock.scene, sceneClock.shot, sceneClock.sceneElapsedTo, { running: false }, ); } return; } const startedAt = this.now(); const timer = this.schedule(() => { if (this._shotProgressTimer !== timer) return; if ( generation !== this._shotGeneration || this._destroyed || token.cancelled || token.signal?.aborted || this.isRunning() ) { this.cancel(timer); if (this._shotProgressTimer === timer) this._shotProgressTimer = null; return; } const fraction = clamp01((this.now() - startedAt) / (seconds * 1000)); this.onProgress(from + (to - from) * fraction); if (generation !== this._shotGeneration || this._destroyed) return; if (sceneClock) { this.publish( sceneClock.scene, sceneClock.shot, sceneClock.sceneElapsedFrom + (sceneClock.sceneElapsedTo - sceneClock.sceneElapsedFrom) * fraction, { running: false }, ); } if (fraction >= 1) { this.cancel(timer); if (this._shotProgressTimer === timer) this._shotProgressTimer = null; } }, 100); timer.unref?.(); this._shotProgressTimer = timer; } startScene(scene, shot, token) { this.stopSceneTimer(); const generation = this._sceneGeneration; const timing = this.timingForShot(scene, shot); if ( generation !== this._sceneGeneration || this._destroyed || token.cancelled || token.signal?.aborted ) return timing; const startedAt = this.now(); this.publish(scene, shot, timing.startElapsedSec, { running: true, }); if ( generation !== this._sceneGeneration || this._destroyed || token.cancelled || token.signal?.aborted ) return timing; const timer = this.schedule(() => { if (this._sceneClockTimer !== timer) return; if ( this._destroyed || token.cancelled || token.signal?.aborted || !this.isRunning() ) { this.cancel(timer); if (this._sceneClockTimer === timer) this._sceneClockTimer = null; return; } const shotElapsedSec = Math.min( timing.durationSec, (this.now() - startedAt) / 1000, ); this.publish(scene, shot, timing.startElapsedSec + shotElapsedSec, { running: true, }); }, 50); timer.unref?.(); this._sceneClockTimer = timer; return timing; } }