summaryrefslogtreecommitdiff
path: root/apps/web/src/core
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-13 04:41:33 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-13 05:08:29 +0200
commit7d4e166678c35d48b22b8d1d05639f981a956a8b (patch)
treeeb596d4f4609053524224169c247db24052db353 /apps/web/src/core
parentcad88ee4d1c9e06e7529b88ef5e5d3fdf3f89149 (diff)
downloadopencut-7d4e166678c35d48b22b8d1d05639f981a956a8b.tar.gz
fix: text measurement, rotation handle, video cache races, playback sync
Made-with: Cursor
Diffstat (limited to 'apps/web/src/core')
-rw-r--r--apps/web/src/core/managers/playback-manager.ts124
-rw-r--r--apps/web/src/core/managers/renderer-manager.ts11
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts60
3 files changed, 143 insertions, 52 deletions
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<string, AnimationValue | null>();
+
+ 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 =