Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 46 additions & 7 deletions web/src/components/preview/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,16 +93,50 @@ export function Preview() {
// The playhead still moves instantly; the composite is fetched only once the
// playhead has settled for ~140ms (i.e. you stop scrubbing). See the perf
// issue for the proper fix (a streaming playback/scrub engine).
const composeFrame = useDebounced(
Math.min(Math.round(activeFrame), Math.max(0, timelineTotal - 1)),
140,
);
const timelineFrameUrl = useTimelineFrame(
//
// The frame the composite should show: rounded, clamped playhead. Computed once
// and shared by the request AND the readiness gate so `readyFrame === targetFrame`
// can actually match.
const targetFrame = Math.min(Math.round(activeFrame), Math.max(0, timelineTotal - 1));
// Debounce ONLY for paused scrubbing. The play→pause settle bypasses it below.
const scrubFrame = useDebounced(targetFrame, 140);

// Settle window: spans the play→pause edge until the composite for the EXACT
// stop frame is ready. During it we keep the played <video> frame painted (the
// browser holds a paused element's last decoded frame = the true stop frame) and
// request that frame immediately (no 140ms debounce), so the preview never
// flashes the earlier/stale pre-playback composite before snapping to the real
// pause frame — the WebView analog of upstream's single AVPlayerLayer staying
// parked on the player's current CMTime when paused.
const [settling, setSettling] = useState(false);
const prevPlaying = useRef(isPlaying);
if (prevPlaying.current !== isPlaying) {
// Set-state-during-render to adjust on the isPlaying edge with no wasted commit
// (React re-renders before painting): entering pause → settle; (re)play → end.
prevPlaying.current = isPlaying;
setSettling(!isPlaying);
}

const composeFrame = settling ? targetFrame : scrubFrame;
const { dataUrl: timelineFrameUrl, readyFrame } = useTimelineFrame(
composeFrame,
!previewing && timeline.tracks.length > 0 && !isPlaying,
timeline,
0,
);

// End the settle once the composite has caught up to the exact stop frame, OR
// after a short ceiling so a failed/never-resolving composite (e.g. outside
// Tauri) can't pin the held video frame forever — fall back to the <img> path.
useEffect(() => {
if (!settling) return;
if (!isPlaying && timelineFrameUrl !== null && readyFrame === targetFrame) {
setSettling(false);
return;
}
const id = window.setTimeout(() => setSettling(false), 800);
return () => window.clearTimeout(id);
}, [settling, isPlaying, timelineFrameUrl, readyFrame, targetFrame]);
const fps = timeline.fps;
const total = previewing
? Math.max(0, Math.round(mediaDuration * fps))
Expand Down Expand Up @@ -162,7 +196,12 @@ export function Preview() {
it stays mounted even when paused so audio/video elements
survive the pause→play transition (upstream VideoEngine model). */}
{!previewItem && timelineHasContent && (
<TimelinePlayback timeline={timeline} fps={fps} playing={isPlaying} />
<TimelinePlayback
timeline={timeline}
fps={fps}
playing={isPlaying}
holdVisible={isPlaying || settling}
/>
)}
{previewItem ? (
<MediaPreview
Expand All @@ -172,7 +211,7 @@ export function Preview() {
onDuration={setMediaDuration}
onPlayingChange={setMediaPlaying}
/>
) : isPlaying ? null : timelineFrameUrl ? (
) : isPlaying || settling ? null : timelineFrameUrl ? (
// Rust GPU composite of the timeline at the current playhead (#47).
<img
src={timelineFrameUrl}
Expand Down
26 changes: 23 additions & 3 deletions web/src/components/preview/TimelinePlaybackLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,27 @@ const SEEK_EPSILON_FRAMES = 2;
* yet (just mounted/seeked); advance by dt and nudge it rather than snapping. */
const MASTER_ALIGN_FRAMES = 15;

export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timeline; fps: number; playing: boolean }) {
/**
* `playing` drives the clock/effect (claim, advance the playhead, pause elements
* on stop). `holdVisible` drives only PAINT: it stays true through the brief
* play→pause "settle" so the already-paused <video> keeps showing its last
* decoded frame — the true stop frame — until the composite <img> for that exact
* frame is ready (Preview swaps then). This is the WebView analog of upstream's
* single AVPlayerLayer staying parked on the player's CMTime: no backward flash
* to an earlier composite, no black. The element is still paused (no motion/audio)
* whenever `playing` is false; `holdVisible` only keeps the frozen frame painted.
*/
export function TimelinePlayback({
timeline,
fps,
playing,
holdVisible,
}: {
timeline: Timeline;
fps: number;
playing: boolean;
holdVisible: boolean;
}) {
// Subscribe to activeFrame so the right clips stay mounted as the playhead moves.
const activeFrame = useEditorUiStore((s) => s.activeFrame);
const items = useMediaStore((s) => s.items);
Expand Down Expand Up @@ -255,7 +275,7 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin
playsInline
preload="auto"
onLoadedData={seekOnLoad(visual.clip)}
style={{ ...fill, opacity: playing ? clipOpacity(visual.clip) : 0 }}
style={{ ...fill, opacity: holdVisible ? clipOpacity(visual.clip) : 0 }}
/>
)}
{visual && visualUrl && visual.clip.mediaType === "image" && (
Expand All @@ -264,7 +284,7 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin
src={visualUrl}
alt=""
draggable={false}
style={{ ...fill, opacity: playing ? clipOpacity(visual.clip) : 0 }}
style={{ ...fill, opacity: holdVisible ? clipOpacity(visual.clip) : 0 }}
/>
)}
{audios.map((a) => {
Expand Down
25 changes: 21 additions & 4 deletions web/src/components/preview/useTimelineFrame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,33 @@
*
* `enabled` gates fetching (Timeline tab active, not single-media preview).
* `refreshKey` forces a refetch when the document changes (pass the timeline
* snapshot). Returns null outside Tauri and before the first frame resolves.
* snapshot). Returns `{ dataUrl, readyFrame }`; both are null outside Tauri and
* before the first frame resolves. `readyFrame` is the frame the current
* `dataUrl` was composited for, so callers can tell when the composite has
* caught up to the frame they actually want.
*/

import { useEffect, useRef, useState } from "react";
import { compositeFrame, isTauri } from "../../lib/api";

export interface TimelineFrameResult {
/** Composited PNG data URL for `readyFrame`, or null before the first resolves. */
dataUrl: string | null;
/** The `frame` argument that produced the current `dataUrl`. Lets callers gate
* a surface swap on "the composite now equals the exact frame I want" (used by
* Preview to hold the played video frame until the stop-frame composite lands,
* instead of flashing a stale earlier frame). */
readyFrame: number | null;
}

export function useTimelineFrame(
frame: number,
enabled: boolean,
refreshKey: unknown,
minIntervalMs = 0,
): string | null {
): TimelineFrameResult {
const [dataUrl, setDataUrl] = useState<string | null>(null);
const [readyFrame, setReadyFrame] = useState<number | null>(null);
const inFlight = useRef(false);
const pending = useRef<number | null>(null);
const lastStart = useRef(0);
Expand All @@ -44,7 +58,10 @@ export function useTimelineFrame(
lastStart.current = performance.now();
void compositeFrame(f)
.then((res) => {
if (res && enabledRef.current) setDataUrl(res.dataUrl);
if (res && enabledRef.current) {
setDataUrl(res.dataUrl);
setReadyFrame(f);
}
})
.catch(() => {
// A failed composite leaves the last good frame.
Expand Down Expand Up @@ -99,5 +116,5 @@ export function useTimelineFrame(
[],
);

return dataUrl;
return { dataUrl, readyFrame };
}
Loading