From 7d4e166678c35d48b22b8d1d05639f981a956a8b Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 13 Apr 2026 04:41:33 +0200 Subject: fix: text measurement, rotation handle, video cache races, playback sync Made-with: Cursor --- apps/web/src/core/managers/playback-manager.ts | 124 +++++++++++++++++-------- apps/web/src/core/managers/renderer-manager.ts | 11 +++ apps/web/src/core/managers/timeline-manager.ts | 60 +++++++++--- 3 files changed, 143 insertions(+), 52 deletions(-) (limited to 'apps/web/src/core/managers') diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts index 47909bfe..341a0eae 100644 --- a/apps/web/src/core/managers/playback-manager.ts +++ b/apps/web/src/core/managers/playback-manager.ts @@ -13,24 +13,32 @@ export class PlaybackManager { private playbackTimer: number | null = null; private playbackStartWallTime = 0; private playbackStartTime = 0; + private timelineScopeBound = false; - constructor(private editor: EditorCore) { - this.editor.timeline.subscribe(() => { - const maxTime = this.editor.timeline.getTotalDuration(); - if (this.currentTime > maxTime && maxTime > 0) { - this.currentTime = maxTime; - this.notify(); - } - }); + constructor(private editor: EditorCore) {} + + bindTimelineScope(): void { + if (this.timelineScopeBound) { + return; + } + + const reconcile = () => { + this.reconcileTimelineScope(); + }; + this.editor.timeline.subscribe(reconcile); + this.editor.scenes.subscribe(reconcile); + this.timelineScopeBound = true; + this.reconcileTimelineScope(); } play(): void { const maxTime = this.editor.timeline.getTotalDuration(); + if (maxTime <= 0) { + return; + } - if (maxTime > 0) { - if (this.currentTime >= maxTime) { - this.seek({ time: 0 }); - } + if (this.currentTime >= maxTime) { + this.seek({ time: 0 }); } this.isPlaying = true; @@ -53,19 +61,13 @@ export class PlaybackManager { } seek({ time }: { time: number }): void { - const maxTime = this.editor.timeline.getTotalDuration(); - this.currentTime = Math.max(0, Math.min(maxTime, time)); + this.currentTime = this.clampTimeToTimeline(time); if (this.isPlaying) { this.playbackStartWallTime = performance.now(); this.playbackStartTime = this.currentTime; } this.notify(); - - window.dispatchEvent( - new CustomEvent("playback-seek", { - detail: { time: this.currentTime }, - }), - ); + this.dispatchSeekEvent(this.currentTime); } setVolume({ volume }: { volume: number }): void { @@ -131,6 +133,29 @@ export class PlaybackManager { return () => this.listeners.delete(listener); } + private reconcileTimelineScope(): void { + const maxTime = this.editor.timeline.getTotalDuration(); + const nextTime = this.clampTimeToTimeline(this.currentTime); + const shouldPause = this.isPlaying && nextTime >= maxTime; + const timeChanged = nextTime !== this.currentTime; + + if (!timeChanged && !shouldPause) { + return; + } + + if (shouldPause) { + this.isPlaying = false; + this.stopTimer(); + } + + this.currentTime = nextTime; + this.notify(); + + if (timeChanged) { + this.dispatchSeekEvent(this.currentTime); + } + } + private notify(): void { this.listeners.forEach((fn) => { fn(); @@ -158,31 +183,54 @@ export class PlaybackManager { if (!this.isPlaying) return; const fps = this.editor.project.getActive()?.settings.fps; - const elapsedSeconds = (performance.now() - this.playbackStartWallTime) / 1000; - const rawTime = this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND); - const newTime = fps ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime) : rawTime; + const elapsedSeconds = + (performance.now() - this.playbackStartWallTime) / 1000; + const rawTime = + this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND); + const newTime = fps + ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime) + : rawTime; const maxTime = this.editor.timeline.getTotalDuration(); - if (maxTime > 0 && newTime >= maxTime) { + if (newTime >= maxTime) { this.pause(); this.currentTime = maxTime; this.notify(); - - window.dispatchEvent( - new CustomEvent("playback-seek", { - detail: { time: maxTime }, - }), - ); - } else { - this.currentTime = newTime; - - window.dispatchEvent( - new CustomEvent("playback-update", { - detail: { time: newTime }, - }), - ); + this.dispatchSeekEvent(maxTime); + return; } + this.currentTime = newTime; + this.dispatchUpdateEvent(newTime); this.playbackTimer = requestAnimationFrame(this.updateTime); }; + + private clampTimeToTimeline(time: number): number { + const maxTime = this.editor.timeline.getTotalDuration(); + return Math.max(0, Math.min(maxTime, time)); + } + + private dispatchSeekEvent(time: number): void { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent( + new CustomEvent("playback-seek", { + detail: { time }, + }), + ); + } + + private dispatchUpdateEvent(time: number): void { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent( + new CustomEvent("playback-update", { + detail: { time }, + }), + ); + } } diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts index eec4bf37..495292e6 100644 --- a/apps/web/src/core/managers/renderer-manager.ts +++ b/apps/web/src/core/managers/renderer-manager.ts @@ -15,10 +15,21 @@ type SnapshotResult = export class RendererManager { private renderTree: RootNode | null = null; + private _isDegraded = false; private listeners = new Set<() => void>(); constructor(private editor: EditorCore) {} + get isDegraded(): boolean { + return this._isDegraded; + } + + setDegraded(degraded: boolean): void { + if (this._isDegraded === degraded) return; + this._isDegraded = degraded; + this.notify(); + } + setRenderTree({ renderTree }: { renderTree: RootNode | null }): void { this.renderTree = renderTree; this.notify(); diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index 74870a95..70075b98 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -5,7 +5,6 @@ import type { TrackType, TimelineTrack, TimelineElement, - ClipboardItem, RetimeConfig, } from "@/lib/timeline"; import { calculateTotalDuration } from "@/lib/timeline"; @@ -23,6 +22,11 @@ import type { AnimationValue, ScalarCurveKeyframePatch, } from "@/lib/animation/types"; +import { + getElementLocalTime, + resolveAnimationTarget, + resolveAnimationPathValueAtTime, +} from "@/lib/animation"; import { lastFrameTime } from "opencut-wasm"; import { BatchCommand } from "@/lib/commands"; import { @@ -35,7 +39,6 @@ import { DuplicateElementsCommand, UpdateElementsCommand, SplitElementsCommand, - PasteCommand, MoveElementCommand, TracksSnapshotCommand, UpsertKeyframeCommand, @@ -244,18 +247,6 @@ export class TimelineManager { return result; } - pasteAtTime({ - time, - clipboardItems, - }: { - time: number; - clipboardItems: ClipboardItem[]; - }): { trackId: string; elementId: string }[] { - const command = new PasteCommand({ time, clipboardItems }); - this.editor.command.execute({ command }); - return command.getPastedElements(); - } - deleteElements({ elements, }: { @@ -492,6 +483,45 @@ export class TimelineManager { return; } + // Pre-sample values at playhead for each (element, property) pair. + // This preserves "what you see is what you get" when all keyframes are deleted. + const playheadTime = this.editor.playback.getCurrentTime(); + const valueAtPlayheadMap = new Map(); + + for (const { trackId, elementId, propertyPath } of keyframes) { + const key = `${elementId}:${propertyPath}`; + if (valueAtPlayheadMap.has(key)) { + continue; + } + + const element = this.getElementByRef({ trackId, elementId }); + if (!element) { + valueAtPlayheadMap.set(key, null); + continue; + } + + const localTime = getElementLocalTime({ + timelineTime: playheadTime, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + + const target = resolveAnimationTarget({ element, path: propertyPath }); + const baseValue = target?.getBaseValue() ?? null; + if (baseValue === null) { + valueAtPlayheadMap.set(key, null); + continue; + } + + const value = resolveAnimationPathValueAtTime({ + animations: element.animations, + propertyPath, + localTime, + fallbackValue: baseValue, + }); + valueAtPlayheadMap.set(key, value); + } + const commands = keyframes.map( ({ trackId, elementId, propertyPath, keyframeId }) => new RemoveKeyframeCommand({ @@ -499,6 +529,8 @@ export class TimelineManager { elementId, propertyPath, keyframeId, + valueAtPlayhead: + valueAtPlayheadMap.get(`${elementId}:${propertyPath}`) ?? null, }), ); const command = -- cgit v1.3.1