playback issues
This commit is contained in:
@@ -309,6 +309,20 @@ impl Drop for DecoderSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pack a frame for the JS wire: 8-byte little-endian i64 frame index
|
||||
/// followed by the raw JPEG. The backend is authoritative on which frame
|
||||
/// actually decoded — JS reads the prefix to bind paints to the real frame
|
||||
/// number rather than the optimistic request, which matters when codec
|
||||
/// keyframe-snap or seek-ahead returns something other than the requested
|
||||
/// index. (Today our ffmpeg path is exact-seek so they always match, but
|
||||
/// the prefix makes the system robust to that ever changing.)
|
||||
pub fn pack_frame(idx: u64, jpeg: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(8 + jpeg.len());
|
||||
out.extend_from_slice(&(idx as i64).to_le_bytes());
|
||||
out.extend_from_slice(jpeg);
|
||||
out
|
||||
}
|
||||
|
||||
/// Pick a VAAPI render node. `SAM_TOOL_VAAPI_DEVICE` overrides; otherwise we
|
||||
/// probe `/dev/dri/renderD128..199` for the first that exists. Multi-GPU
|
||||
/// systems can end up with the integrated card on D128 and a discrete card
|
||||
|
||||
@@ -6,6 +6,7 @@ mod extract;
|
||||
mod sam;
|
||||
mod settings;
|
||||
mod state;
|
||||
mod stream;
|
||||
mod user;
|
||||
mod video;
|
||||
|
||||
@@ -62,12 +63,15 @@ fn current_video(state: State<'_, AppState>) -> Result<Option<VideoInfo>, String
|
||||
|
||||
#[tauri::command]
|
||||
fn decode_frame(state: State<'_, AppState>, frame_idx: u64) -> Result<Response, String> {
|
||||
let bytes = {
|
||||
let jpeg = {
|
||||
let mut guard = state.decoder.lock().map_err(|e| format!("lock: {e}"))?;
|
||||
let decoder = guard.as_mut().ok_or("no video loaded")?;
|
||||
decoder.decode_at(frame_idx)?
|
||||
};
|
||||
Ok(Response::new(bytes))
|
||||
// Wire format: 8-byte LE frame number + JPEG bytes. JS unpacks the
|
||||
// prefix in the worker so paints bind to the backend-authoritative
|
||||
// index, not the optimistic request.
|
||||
Ok(Response::new(decoder::pack_frame(frame_idx, &jpeg)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -309,6 +313,10 @@ pub fn run() {
|
||||
sam_health_check,
|
||||
sam_list_models,
|
||||
sam_segment_bbox,
|
||||
stream::start_stream,
|
||||
stream::set_stream_state,
|
||||
stream::stop_stream,
|
||||
stream::stream_current_frame,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
use crate::decoder::DecoderSession;
|
||||
use std::sync::Mutex;
|
||||
use crate::stream::StreamControl;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub struct AppState {
|
||||
pub decoder: Mutex<Option<DecoderSession>>,
|
||||
/// Wrapped in Arc so the stream producer thread can hold its own
|
||||
/// handle and reach back into the decoder without going through Tauri.
|
||||
pub decoder: Arc<Mutex<Option<DecoderSession>>>,
|
||||
/// Atomic control struct read by the stream producer each tick. JS
|
||||
/// mutates via tauri commands; the producer polls it for play / pause /
|
||||
/// speed / target / stride.
|
||||
pub stream_control: Arc<StreamControl>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
decoder: Mutex::new(None),
|
||||
decoder: Arc::new(Mutex::new(None)),
|
||||
stream_control: Arc::new(StreamControl::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
208
sam-tool-tauri/src-tauri/src/stream.rs
Normal file
208
sam-tool-tauri/src-tauri/src/stream.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
//! Forward playback transport. A Rust producer thread decodes via the
|
||||
//! existing DecoderSession and pushes packed frames to the JS side via a
|
||||
//! Tauri Channel — eliminates the per-frame IPC round-trip that pure
|
||||
//! `invoke()` playback paid for. Backward / step / scrub / pause stay on
|
||||
//! the invoke-pull path (see lib.rs::decode_frame).
|
||||
|
||||
use crate::decoder::{pack_frame, DecoderSession};
|
||||
use crate::state::AppState;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::ipc::{Channel, InvokeResponseBody};
|
||||
use tauri::State;
|
||||
|
||||
pub struct StreamControl {
|
||||
/// Bumped on each `start_stream`. Producer threads check this and exit
|
||||
/// when it changes — guarantees only one producer at a time.
|
||||
pub session_id: AtomicU64,
|
||||
/// True while the producer should keep emitting. JS clears via
|
||||
/// `set_stream_state` / `stop_stream` to pause.
|
||||
pub playing: AtomicBool,
|
||||
/// Speed multiplier × 100 (atomic u64; floats aren't atomic).
|
||||
pub speed_x100: AtomicU64,
|
||||
/// Next frame to emit. JS may overwrite this for in-stream seeks.
|
||||
pub current_frame: AtomicU64,
|
||||
/// Advance step (1 = every frame, N = every Nth — frame_skip).
|
||||
pub stride: AtomicU64,
|
||||
/// Stop when current_frame >= total_frames.
|
||||
pub total_frames: AtomicU64,
|
||||
}
|
||||
|
||||
impl StreamControl {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
session_id: AtomicU64::new(0),
|
||||
playing: AtomicBool::new(false),
|
||||
speed_x100: AtomicU64::new(100),
|
||||
current_frame: AtomicU64::new(0),
|
||||
stride: AtomicU64::new(1),
|
||||
total_frames: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn start_stream(
|
||||
state: State<'_, AppState>,
|
||||
channel: Channel<InvokeResponseBody>,
|
||||
from_idx: u64,
|
||||
speed: f64,
|
||||
stride: u64,
|
||||
) -> Result<u64, String> {
|
||||
let (total, fps) = {
|
||||
let guard = state.decoder.lock().map_err(|e| format!("lock: {e}"))?;
|
||||
let info = guard.as_ref().ok_or("no video loaded")?.info();
|
||||
(info.total_frames, info.fps)
|
||||
};
|
||||
if from_idx >= total {
|
||||
return Err("from_idx beyond total_frames".into());
|
||||
}
|
||||
|
||||
let control = state.stream_control.clone();
|
||||
// fetch_add returns the previous value; +1 = our new id. Any existing
|
||||
// producer with the prior id will exit on its next iteration.
|
||||
let sid = control.session_id.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
control
|
||||
.speed_x100
|
||||
.store((speed.max(0.01) * 100.0) as u64, Ordering::SeqCst);
|
||||
control.current_frame.store(from_idx, Ordering::SeqCst);
|
||||
control.stride.store(stride.max(1), Ordering::SeqCst);
|
||||
control.total_frames.store(total, Ordering::SeqCst);
|
||||
// playing must be the LAST flag we set — once it's true the producer
|
||||
// can start consuming the other fields.
|
||||
control.playing.store(true, Ordering::SeqCst);
|
||||
|
||||
let decoder = state.decoder.clone();
|
||||
thread::spawn(move || {
|
||||
producer_loop(decoder, control, channel, sid, fps);
|
||||
});
|
||||
|
||||
Ok(sid)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_stream_state(
|
||||
state: State<'_, AppState>,
|
||||
playing: Option<bool>,
|
||||
speed: Option<f64>,
|
||||
target_frame: Option<u64>,
|
||||
stride: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
let c = &state.stream_control;
|
||||
if let Some(s) = speed {
|
||||
c.speed_x100
|
||||
.store((s.max(0.01) * 100.0) as u64, Ordering::SeqCst);
|
||||
}
|
||||
if let Some(t) = target_frame {
|
||||
c.current_frame.store(t, Ordering::SeqCst);
|
||||
}
|
||||
if let Some(s) = stride {
|
||||
c.stride.store(s.max(1), Ordering::SeqCst);
|
||||
}
|
||||
if let Some(p) = playing {
|
||||
c.playing.store(p, Ordering::SeqCst);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stop_stream(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let c = &state.stream_control;
|
||||
c.playing.store(false, Ordering::SeqCst);
|
||||
// Bump session_id too so a producer that misses the playing flag still
|
||||
// exits on its session check.
|
||||
c.session_id.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stream_current_frame(state: State<'_, AppState>) -> Result<u64, String> {
|
||||
Ok(state.stream_control.current_frame.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
fn producer_loop(
|
||||
decoder: Arc<Mutex<Option<DecoderSession>>>,
|
||||
control: Arc<StreamControl>,
|
||||
channel: Channel<InvokeResponseBody>,
|
||||
my_sid: u64,
|
||||
fps: f64,
|
||||
) {
|
||||
let mut next_tick = Instant::now();
|
||||
loop {
|
||||
if control.session_id.load(Ordering::Relaxed) != my_sid {
|
||||
return;
|
||||
}
|
||||
if !control.playing.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let speed = (control.speed_x100.load(Ordering::Relaxed) as f64 / 100.0).max(0.01);
|
||||
let stride = control.stride.load(Ordering::Relaxed).max(1);
|
||||
let total = control.total_frames.load(Ordering::Relaxed);
|
||||
let frame = control.current_frame.load(Ordering::Relaxed);
|
||||
|
||||
if total == 0 || frame >= total {
|
||||
control.playing.store(false, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decode this frame via the existing DecoderSession (preserves
|
||||
// hwaccel fallback + stderr capture + frame cache).
|
||||
let jpeg = {
|
||||
let mut guard = match decoder.lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => return,
|
||||
};
|
||||
match guard.as_mut() {
|
||||
Some(d) => d.decode_at(frame),
|
||||
None => Err("no video loaded".into()),
|
||||
}
|
||||
};
|
||||
|
||||
let send_ok = match jpeg {
|
||||
Ok(bytes) => {
|
||||
let packed = pack_frame(frame, &bytes);
|
||||
channel.send(InvokeResponseBody::Raw(packed)).is_ok()
|
||||
}
|
||||
Err(_) => {
|
||||
// Surface decode errors as an empty packed frame so JS knows
|
||||
// to stop. With just an 8-byte prefix and no JPEG it's
|
||||
// unambiguous on the receive side.
|
||||
let packed = pack_frame(frame, &[]);
|
||||
let _ = channel.send(InvokeResponseBody::Raw(packed));
|
||||
false
|
||||
}
|
||||
};
|
||||
if !send_ok {
|
||||
// Channel closed (frontend tore down) — stop cleanly.
|
||||
control.playing.store(false, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
|
||||
// Advance the cursor. JS may have already moved it (an in-stream
|
||||
// seek) — in that case the next iteration picks up the new value.
|
||||
let next_frame = frame.saturating_add(stride);
|
||||
// Only advance if no one else moved it (compare_exchange would be
|
||||
// strictly correct, but a plain store is fine: a race here just
|
||||
// means we emit the seeked-to frame on the next iteration).
|
||||
let _ = control.current_frame.compare_exchange(
|
||||
frame,
|
||||
next_frame,
|
||||
Ordering::SeqCst,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
|
||||
// Pace: stride frames per (fps × speed) per second.
|
||||
let frame_interval = Duration::from_secs_f64(stride as f64 / (fps * speed));
|
||||
next_tick += frame_interval;
|
||||
let now = Instant::now();
|
||||
if next_tick > now {
|
||||
thread::sleep(next_tick - now);
|
||||
} else {
|
||||
// Behind real-time — reset baseline instead of accumulating debt.
|
||||
next_tick = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { invoke, Channel } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
CocoCategory,
|
||||
CocoShape,
|
||||
@@ -70,6 +70,25 @@ export const api = {
|
||||
readFolderSource: (folder: string) =>
|
||||
invoke<string | null>("read_folder_source", { folder }),
|
||||
|
||||
// Forward playback uses a Rust producer thread that pushes packed-frame
|
||||
// ArrayBuffers via this channel. Pass `new Channel<ArrayBuffer>()` from the
|
||||
// caller and attach `.onmessage` before invoking — Rust starts sending
|
||||
// immediately.
|
||||
startStream: (
|
||||
channel: Channel<ArrayBuffer>,
|
||||
fromIdx: number,
|
||||
speed: number,
|
||||
stride: number
|
||||
) => invoke<number>("start_stream", { channel, fromIdx, speed, stride }),
|
||||
setStreamState: (state: {
|
||||
playing?: boolean;
|
||||
speed?: number;
|
||||
targetFrame?: number;
|
||||
stride?: number;
|
||||
}) => invoke<void>("set_stream_state", state),
|
||||
stopStream: () => invoke<void>("stop_stream"),
|
||||
streamCurrentFrame: () => invoke<number>("stream_current_frame"),
|
||||
|
||||
getSettings: () => invoke<Settings>("get_settings"),
|
||||
saveSettings: (settings: Settings) =>
|
||||
invoke<void>("save_settings", { settings }),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import { api } from "../ipc";
|
||||
import type { CocoShape, ImportedCoco, Settings, VideoInfo } from "../types";
|
||||
import { FrameCanvas } from "../components/extract/FrameCanvas";
|
||||
import { drawOverlay } from "../components/extract/overlay";
|
||||
import { sharedFrameDecoderPool } from "../services/frameDecoderPool";
|
||||
|
||||
const SPEEDS = [0.25, 0.5, 1, 1.5, 2, 3, 4];
|
||||
const FRAME_BITMAP_CACHE_LIMIT = 24;
|
||||
@@ -186,34 +188,44 @@ export function ExtractMode({ username, settings }: Props) {
|
||||
);
|
||||
|
||||
// Returns true if we drew, false if we were preempted by a newer request,
|
||||
// and throws if bitmap creation failed — pump uses this to decide whether
|
||||
// to mark the frame rendered or surface the error to the playback loop.
|
||||
// and throws if worker decode failed. The packed buffer carries an 8-byte
|
||||
// LE frame-number prefix; the worker is authoritative on which frame
|
||||
// actually decoded, so we use that for caching + drawing rather than the
|
||||
// optimistic `atFrameIdx` (`atFrameIdx` only stays useful as a fallback
|
||||
// for the preempt check).
|
||||
const renderFrame = useCallback(
|
||||
async (bytes: ArrayBuffer, atFrameIdx: number): Promise<boolean> => {
|
||||
// Diagnostic timing log: set `window.SAM_FRAME_DEBUG = true` in devtools
|
||||
// on a slow machine to see per-frame bitmap and draw timings — confirms
|
||||
// where the bottleneck lives (decode vs createImageBitmap vs drawImage).
|
||||
const debug = (window as unknown as { SAM_FRAME_DEBUG?: boolean })
|
||||
.SAM_FRAME_DEBUG === true;
|
||||
const t0 = debug ? performance.now() : 0;
|
||||
const blob = new Blob([bytes], { type: "image/jpeg" });
|
||||
let frameNumber: number;
|
||||
let bitmap: ImageBitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(blob);
|
||||
const result = await sharedFrameDecoderPool.decode(bytes);
|
||||
frameNumber = result.frameNumber;
|
||||
bitmap = result.bitmap;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`createImageBitmap failed: ${e instanceof Error ? e.message : String(e)}`
|
||||
`worker decode failed: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
}
|
||||
const t1 = debug ? performance.now() : 0;
|
||||
rememberBitmap(atFrameIdx, bitmap);
|
||||
if (requestedIdx.current !== atFrameIdx) return false;
|
||||
drawBitmap(bitmap, atFrameIdx);
|
||||
rememberBitmap(frameNumber, bitmap);
|
||||
// Preempt check: if a newer request landed during decode, drop this
|
||||
// paint. Match against either the prefix frame number (authoritative)
|
||||
// or the original request — keyframe snap can make them disagree.
|
||||
if (
|
||||
requestedIdx.current !== frameNumber &&
|
||||
requestedIdx.current !== atFrameIdx
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
drawBitmap(bitmap, frameNumber);
|
||||
if (debug) {
|
||||
const t2 = performance.now();
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(
|
||||
`[SAM] f${atFrameIdx} bytes=${bytes.byteLength} bitmap=${(t1 - t0).toFixed(0)}ms draw=${(t2 - t1).toFixed(0)}ms`
|
||||
`[SAM] f${frameNumber} bytes=${bytes.byteLength} decode=${(t1 - t0).toFixed(0)}ms draw=${(t2 - t1).toFixed(0)}ms`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
@@ -329,109 +341,126 @@ export function ExtractMode({ username, settings }: Props) {
|
||||
return () => ro.disconnect();
|
||||
}, [video, drawBitmap, getCachedBitmap, pump]);
|
||||
|
||||
// --- playback loop ---------------------------------------------------
|
||||
// Render-paced: advance ONLY after the previous frame actually drew. On a
|
||||
// fast box this hits target fps; on a slow box it plays smoothly at the
|
||||
// achievable decode/render rate instead of dropping 9-of-10 frames at the
|
||||
// canvas while the wall-clock UI counter races ahead.
|
||||
// --- playback (Channel-push transport) -----------------------------------
|
||||
// Forward playback runs through a Rust producer thread that pushes packed
|
||||
// frames over a Tauri Channel — eliminates the per-frame IPC round-trip
|
||||
// that was the main stutter source. Backward / step / scrub / pause all
|
||||
// stay on the invoke-pull path (pump() above). Three guards live here:
|
||||
// * `paused` — set true on review-mode stop so any in-flight messages
|
||||
// queued behind us don't paint;
|
||||
// * `lastPainted` — direction-aware out-of-order drop in case the worker
|
||||
// pool returns N+1 before N;
|
||||
// * cleanup stops the stream and re-fetches the last painted frame via
|
||||
// invoke so the canvas exactly matches `frameIdx` on pause.
|
||||
useEffect(() => {
|
||||
if (!playing || !video) return;
|
||||
let cancelled = false;
|
||||
let nextDueAt = performance.now();
|
||||
|
||||
const waitForFrame = (target: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
if (
|
||||
cancelled ||
|
||||
renderedIdx.current === target ||
|
||||
decodeErrorRef.current !== null
|
||||
) {
|
||||
resolve();
|
||||
let paused = false;
|
||||
let lastPainted = frameIdxRef.current;
|
||||
const skip = Math.max(1, frameSkipRef.current | 0);
|
||||
const startFrom = Math.min(
|
||||
video.total_frames - 1,
|
||||
frameIdxRef.current + skip
|
||||
);
|
||||
if (startFrom <= frameIdxRef.current) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(check);
|
||||
|
||||
const channel = new Channel<ArrayBuffer>();
|
||||
channel.onmessage = async (buffer: ArrayBuffer) => {
|
||||
if (cancelled || paused) return;
|
||||
// Empty-frame sentinel from the producer = decode failed.
|
||||
if (buffer.byteLength <= 8) {
|
||||
setPlaybackError("decode failed during playback");
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
let frameNumber: number;
|
||||
let bitmap: ImageBitmap;
|
||||
try {
|
||||
const result = await sharedFrameDecoderPool.decode(buffer);
|
||||
frameNumber = result.frameNumber;
|
||||
bitmap = result.bitmap;
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setPlaybackError(e instanceof Error ? e.message : String(e));
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
if (cancelled || paused) {
|
||||
bitmap.close();
|
||||
return;
|
||||
}
|
||||
// Direction-aware out-of-order drop: forward play is monotonic, so
|
||||
// any frame number < the last painted came from a parallel worker
|
||||
// and is stale. Cheap defence against worker-pool races.
|
||||
if (frameNumber < lastPainted) {
|
||||
bitmap.close();
|
||||
return;
|
||||
}
|
||||
lastPainted = frameNumber;
|
||||
rememberBitmap(frameNumber, bitmap);
|
||||
drawBitmap(bitmap, frameNumber);
|
||||
renderedIdx.current = frameNumber;
|
||||
requestedIdx.current = frameNumber;
|
||||
frameIdxRef.current = frameNumber;
|
||||
setFrameIdx(frameNumber);
|
||||
decodeErrorRef.current = null;
|
||||
|
||||
// Review-mode stops AT the annotated frame, so the canvas already
|
||||
// shows it by the time we ask the producer to halt.
|
||||
if (reviewMode && annotatedFrames.current.has(frameNumber)) {
|
||||
paused = true;
|
||||
try { await api.stopStream(); } catch { /* ignore */ }
|
||||
setPlaying(false);
|
||||
}
|
||||
};
|
||||
check();
|
||||
|
||||
api
|
||||
.startStream(channel, startFrom, speed, skip)
|
||||
.catch((e) => {
|
||||
if (cancelled) return;
|
||||
setPlaybackError(`start_stream failed: ${e}`);
|
||||
setPlaying(false);
|
||||
});
|
||||
|
||||
const loop = async () => {
|
||||
while (!cancelled && playing) {
|
||||
const skip = Math.max(1, frameSkipRef.current | 0);
|
||||
const current = frameIdxRef.current;
|
||||
let target = Math.min(video.total_frames - 1, current + skip);
|
||||
if (target <= current) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
// Review-mode scan: with skip > 1 a naive +skip jump can leap past
|
||||
// annotations between current+1 and current+skip. Walk that window
|
||||
// and clamp `target` down to the first annotated frame.
|
||||
if (reviewMode) {
|
||||
for (let i = current + 1; i <= target; i++) {
|
||||
if (annotatedFrames.current.has(i)) {
|
||||
target = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
requestedIdx.current = target;
|
||||
// Fast path: await pump directly. When pump owns the queue it
|
||||
// resolves the instant target is drawn (no 16ms rAF latency tax).
|
||||
// If another caller (ResizeObserver / overlay-change effect) was
|
||||
// already running pump, our call short-circuits — fall back to
|
||||
// rAF polling in that case so we still wait correctly.
|
||||
await pump();
|
||||
if (
|
||||
!cancelled &&
|
||||
renderedIdx.current !== target &&
|
||||
decodeErrorRef.current === null
|
||||
) {
|
||||
await waitForFrame(target);
|
||||
}
|
||||
if (cancelled) return;
|
||||
// Decode failed mid-play: stop and surface so the UI doesn't claim to
|
||||
// be playing while the canvas is frozen on the last good frame.
|
||||
if (decodeErrorRef.current) {
|
||||
setPlaybackError(decodeErrorRef.current);
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
const advanced = Math.max(1, target - current);
|
||||
// Real-time pacing regardless of skip: skip controls density,
|
||||
// `speed` controls speed. Without the advanced-frame factor here,
|
||||
// skip=3 would play at 3x wall-clock, which is what the speed slider
|
||||
// is for. Compute after review-mode clamping so annotated stops do not
|
||||
// wait for the full skip window.
|
||||
const interval = (advanced * 1000) / (video.fps * speed);
|
||||
// Keep the fast playback loop's cursor in sync immediately. Waiting
|
||||
// for React's frameIdx effect can make a slow decode path replay the
|
||||
// same target once and add an extra frame interval per rendered frame.
|
||||
frameIdxRef.current = target;
|
||||
setFrameIdx(target);
|
||||
// Pause AFTER rendering so the user actually sees the annotated frame.
|
||||
if (reviewMode && annotatedFrames.current.has(target)) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const due = nextDueAt + interval;
|
||||
if (due > now) {
|
||||
await new Promise((r) => setTimeout(r, due - now));
|
||||
nextDueAt = due;
|
||||
} else {
|
||||
// Behind real-time — don't accumulate debt and then "catch up" by
|
||||
// skipping frames; just reset the clock to now.
|
||||
nextDueAt = now;
|
||||
}
|
||||
}
|
||||
};
|
||||
loop();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// Fire-and-forget: stop the producer, then re-fetch the last painted
|
||||
// frame via the authoritative invoke path so the canvas matches
|
||||
// `frameIdx` exactly (the producer may have been mid-flight).
|
||||
const lastFrame = frameIdxRef.current;
|
||||
api
|
||||
.stopStream()
|
||||
.catch(() => { /* ignore */ })
|
||||
.then(() => api.decodeFrame(lastFrame))
|
||||
.then((buf) => sharedFrameDecoderPool.decode(buf))
|
||||
.then(({ frameNumber, bitmap }) => {
|
||||
// Newer request may have started by the time this resolves —
|
||||
// only paint if it still matches.
|
||||
if (requestedIdx.current !== frameNumber) {
|
||||
bitmap.close();
|
||||
return;
|
||||
}
|
||||
rememberBitmap(frameNumber, bitmap);
|
||||
drawBitmap(bitmap, frameNumber);
|
||||
renderedIdx.current = frameNumber;
|
||||
})
|
||||
.catch(() => { /* invoke path will recover on next seek */ });
|
||||
};
|
||||
}, [playing, video, speed, reviewMode, pump]);
|
||||
}, [playing, video, speed, reviewMode, drawBitmap, rememberBitmap]);
|
||||
|
||||
// Live-update the producer when speed or frame-skip changes mid-play, so
|
||||
// the user doesn't have to pause + resume to see the new setting.
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
api.setStreamState({ speed }).catch(() => { /* ignore */ });
|
||||
}, [playing, speed]);
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
api.setStreamState({ stride: Math.max(1, settings?.frame_skip ?? 1) }).catch(() => { /* ignore */ });
|
||||
}, [playing, settings?.frame_skip]);
|
||||
|
||||
// --- navigation helpers ----------------------------------------------
|
||||
const seekToFrame = useCallback((idx: number) => {
|
||||
@@ -443,11 +472,19 @@ export function ExtractMode({ username, settings }: Props) {
|
||||
if (cached) {
|
||||
drawBitmap(cached, clamped);
|
||||
renderedIdx.current = clamped;
|
||||
} else {
|
||||
} else if (!playing) {
|
||||
// Pump only handles seeks while paused. During Channel-push play the
|
||||
// producer is the source of frames; we re-target it instead.
|
||||
pump();
|
||||
}
|
||||
if (playing) {
|
||||
// Hand the new target to the running producer so it resumes from the
|
||||
// scrub destination instead of where it was. The producer's monotonic
|
||||
// session_id keeps the existing channel valid.
|
||||
api.setStreamState({ targetFrame: clamped }).catch(() => { /* ignore */ });
|
||||
}
|
||||
setFrameIdx((prev) => (prev === clamped ? prev : clamped));
|
||||
}, [drawBitmap, getCachedBitmap, pump, video]);
|
||||
}, [drawBitmap, getCachedBitmap, pump, video, playing]);
|
||||
|
||||
const stepFrames = useCallback((delta: number) => {
|
||||
if (!video) return;
|
||||
|
||||
118
sam-tool-tauri/src/services/frameDecoderPool.ts
Normal file
118
sam-tool-tauri/src/services/frameDecoderPool.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
// Worker pool that takes a packed-frame ArrayBuffer (8-byte LE i64 frame
|
||||
// number + JPEG bytes), decodes the JPEG to an ImageBitmap off the main
|
||||
// thread, and returns both via transferable postMessage so the main thread
|
||||
// only does the final drawImage. Inlined as a Blob URL so we don't need a
|
||||
// separate build entry for the worker file.
|
||||
|
||||
const WORKER_SOURCE = `
|
||||
self.onmessage = async (e) => {
|
||||
const { id, buffer } = e.data;
|
||||
let frameNumber = -1;
|
||||
try {
|
||||
const headerView = new BigInt64Array(buffer, 0, 1);
|
||||
frameNumber = Number(headerView[0]);
|
||||
const jpegView = new Uint8Array(buffer, 8);
|
||||
// Copy out of the transferred buffer so Blob ownership is independent.
|
||||
const jpegCopy = jpegView.slice();
|
||||
const blob = new Blob([jpegCopy], { type: "image/jpeg" });
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
self.postMessage({ id, frameNumber, bitmap }, [bitmap]);
|
||||
} catch (err) {
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
self.postMessage({ id, frameNumber, error: msg });
|
||||
}
|
||||
};
|
||||
`;
|
||||
|
||||
const workerBlob = new Blob([WORKER_SOURCE], {
|
||||
type: "application/javascript",
|
||||
});
|
||||
const workerUrl = URL.createObjectURL(workerBlob);
|
||||
|
||||
export interface DecodeResult {
|
||||
frameNumber: number;
|
||||
bitmap: ImageBitmap;
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
resolve: (r: DecodeResult) => void;
|
||||
reject: (e: Error) => void;
|
||||
}
|
||||
|
||||
interface WorkerMessage {
|
||||
id: number;
|
||||
frameNumber: number;
|
||||
bitmap?: ImageBitmap;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class FrameDecoderPool {
|
||||
private workers: Worker[] = [];
|
||||
private next = 0;
|
||||
private pending = new Map<number, Pending>();
|
||||
private nextId = 1;
|
||||
|
||||
constructor(size: number = 2) {
|
||||
const count = Math.max(1, Math.min(size, 8));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const worker = new Worker(workerUrl);
|
||||
worker.onmessage = (e: MessageEvent<WorkerMessage>) => {
|
||||
const { id, frameNumber, bitmap, error } = e.data;
|
||||
const slot = this.pending.get(id);
|
||||
if (!slot) {
|
||||
// The caller discarded this request — close the bitmap so it
|
||||
// doesn't leak (an ImageBitmap holds GPU memory).
|
||||
bitmap?.close?.();
|
||||
return;
|
||||
}
|
||||
this.pending.delete(id);
|
||||
if (error || !bitmap) {
|
||||
slot.reject(new Error(error ?? "decode failed"));
|
||||
} else {
|
||||
slot.resolve({ frameNumber, bitmap });
|
||||
}
|
||||
};
|
||||
worker.onerror = (e) => {
|
||||
// Surface worker-thread errors to any pending request on this
|
||||
// worker. We don't know which id failed, so reject the oldest.
|
||||
const first = this.pending.entries().next().value;
|
||||
if (first) {
|
||||
const [id, slot] = first;
|
||||
this.pending.delete(id);
|
||||
slot.reject(new Error(`worker error: ${e.message}`));
|
||||
}
|
||||
};
|
||||
this.workers.push(worker);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a packed-frame buffer. The buffer ownership transfers to the
|
||||
* worker — do not read it after this call returns.
|
||||
*/
|
||||
decode(buffer: ArrayBuffer): Promise<DecodeResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = this.nextId++;
|
||||
this.pending.set(id, { resolve, reject });
|
||||
const worker = this.workers[this.next];
|
||||
this.next = (this.next + 1) % this.workers.length;
|
||||
worker.postMessage({ id, buffer }, [buffer]);
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const w of this.workers) w.terminate();
|
||||
this.workers = [];
|
||||
for (const slot of this.pending.values()) {
|
||||
slot.reject(new Error("decoder pool destroyed"));
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared pool used by ExtractMode (and later, AnnotateMode). Two workers is
|
||||
* enough for sequential playback decode; raising it helps the seek path
|
||||
* when several scrub requests overlap.
|
||||
*/
|
||||
export const sharedFrameDecoderPool = new FrameDecoderPool(2);
|
||||
Reference in New Issue
Block a user