From fe54b6c545007c24284979f268099a36d2dfdf9e Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 19 Jul 2026 13:20:02 +0200 Subject: [PATCH 1/2] fix(editor): fix annotation placeholder text and post-reload duration cap Two bugs found during the 1.7.0 RC end-to-end pass: 1. New text annotations were created with the literal string "Enter text..." baked into their actual content, instead of starting empty with a real placeholder. The properties panel's textarea already had a proper `placeholder` attribute wired up, but since the field's *value* was never actually empty, the placeholder never showed and clicking in to type appended after the baked-in text instead of replacing it. Fixed by defaulting new text annotations' content to "" (both on creation and when switching an existing annotation's type to "text"), matching the pattern already used for "image"/"figure"/"blur" types. 2. After Save Project -> Load Project, the editor's scrubber/seek range was capped at the last timeline element's end time instead of the true recording duration. Root cause: `applyLoadedProject` sets `duration` to an inferred placeholder value (max of all region endMs) as a provisional guess before the real video metadata loads, expecting `VideoPlayback`'s `syncResolvedDuration` to correct it once the video element reports its real duration. But `syncResolvedDuration` skips calling `onDurationChange` whenever the resolved value matches `lastResolvedDurationRef` -- a memoization guard against redundant updates that has no way to know `applyLoadedProject` just set `duration` to something else. If the real duration happened to match what the ref already remembered from before the reload (the common case, since it's the same video file), the correction never fired and the inferred placeholder stuck. Fixed by exposing a `resetDurationResolution()` method on `VideoPlaybackRef` that clears the memoization guard, called from `applyLoadedProject` right when it sets the placeholder duration -- forcing the next metadata load to always re-sync regardless of what the guard last saw. Verified via computer-use against a real dev build: - New annotation's text field now shows a genuine empty value (real greyed placeholder, timeline label reads "Empty text"); typing replaces cleanly with no leftover baked-in text. - Loading a saved project with a trim region correctly shows the full original duration (1:10) and scrubs all the way to the end, instead of being capped at the last element's end time (previously 0:47). Full suite: 53 files / 423 tests pass. tsc --noEmit and biome check clean. --- src/components/video-editor/VideoEditor.tsx | 9 +++++++-- src/components/video-editor/VideoPlayback.tsx | 11 +++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index f0bfbd3fe..4954d355f 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -404,6 +404,11 @@ export default function VideoEditor() { } catch { // no-op } + // This inferred duration is only a placeholder until the video element's real + // metadata resolves (see VideoPlayback's syncResolvedDuration). Reset its memoized + // last-resolved-duration guard so that resolution isn't skipped just because the + // real duration happens to match a value already seen from before this load. + videoPlaybackRef.current?.resetDurationResolution(); setIsPlaying(false); setCurrentTime(0); setDuration(inferredDurationMs > 0 ? inferredDurationMs / 1000 : 0); @@ -1477,7 +1482,7 @@ export default function VideoEditor() { startMs: Math.round(span.start), endMs: Math.round(span.end), type: "text", - content: "Enter text...", + content: "", position: { ...DEFAULT_ANNOTATION_POSITION }, size: { ...DEFAULT_ANNOTATION_SIZE }, style: { ...DEFAULT_ANNOTATION_STYLE }, @@ -1616,7 +1621,7 @@ export default function VideoEditor() { if (region.id !== id) return region; const updatedRegion = { ...region, type }; if (type === "text") { - updatedRegion.content = region.textContent || "Enter text..."; + updatedRegion.content = region.textContent || ""; } else if (type === "image") { updatedRegion.content = region.imageContent || ""; } else if (type === "figure") { diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 8b7f2e829..1acc0fa49 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -165,6 +165,14 @@ export interface VideoPlaybackRef { containerRef: React.RefObject; play: () => Promise; pause: () => void; + /** + * Clears the memoized last-resolved-duration guard so the next metadata load + * re-syncs `duration` even if the video's real (resolved) duration happens to + * match a value already seen — needed when a caller (e.g. loading a saved + * project) sets `duration` to something else in between, which the guard has + * no other way to detect. + */ + resetDurationResolution: () => void; } function getResolvedVideoDuration(video: HTMLVideoElement): number | null { @@ -695,6 +703,9 @@ const VideoPlayback = forwardRef( video.pause(); supplementalAudioRef.current?.pause(); }, + resetDurationResolution: () => { + lastResolvedDurationRef.current = null; + }, })); const updateFocusFromClientPoint = (clientX: number, clientY: number) => { From f46e50dc120400d56e6edc65c691f24cc7778caa Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 19 Jul 2026 13:40:59 +0200 Subject: [PATCH 2/2] fix(editor): address CodeRabbit review on #127 Two findings on the original fix: 1. Added regression tests. Extracted the "create empty text annotation" and "resolve content when converting to text" logic out of VideoEditor's inline handlers into createTextAnnotationRegion()/ resolveTextAnnotationContent() in types.ts, so both paths are directly unit-testable without rendering the whole (3000+ line) VideoEditor component. 2. resetDurationResolution() only cleared the memoization guard and relied on a future `loadedmetadata` event to re-sync -- which never fires when reloading a project referencing the same video file, since the