scrub optimization
This commit is contained in:
154
cht/window.py
154
cht/window.py
@@ -1,6 +1,7 @@
|
||||
"""Main application window — wires Timeline to all components."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "4.0")
|
||||
@@ -24,7 +25,8 @@ from cht.transcriber.engine import TranscriberEngine
|
||||
from cht.stream.manager import StreamManager, list_sessions
|
||||
from cht.stream.lifecycle import StreamLifecycle
|
||||
from cht.ui.session_dialog import SessionDialog
|
||||
from cht.session import load_frame_index
|
||||
from cht.session import load_frame_index, load_segment_manifest, rebuild_manifest, global_time_to_segment
|
||||
from cht.scrub.manager import ProxyManager
|
||||
from cht.agent.runner import AgentRunner, check_claude_cli
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -36,6 +38,10 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
self.set_title(APP_NAME)
|
||||
self.set_default_size(1400, 900)
|
||||
self._known_frames = set()
|
||||
self._proxy_mgr = None
|
||||
self._manifest = []
|
||||
self._pending_scrub_global = 0.0
|
||||
self._scrub_pending = False # throttle flag for scrub updates
|
||||
|
||||
# Core components
|
||||
self._timeline = Timeline()
|
||||
@@ -103,6 +109,9 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
self._transcript_panel.connect("selection-changed", self._on_transcript_selection_changed)
|
||||
self._transcript_panel.connect("min-chunk-changed", self._on_min_chunk_changed)
|
||||
self._transcript_panel.connect("lines-per-group-changed", self._on_lines_per_group_changed)
|
||||
# Seek-to-timestamp from panels (double-click)
|
||||
self._frames_panel.connect("seek-requested", self._on_panel_seek)
|
||||
self._transcript_panel.connect("seek-requested", self._on_panel_seek)
|
||||
|
||||
log.info("Window initialized")
|
||||
GLib.idle_add(self._check_agent_auth)
|
||||
@@ -129,7 +138,11 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
self._start_stream(session_id=session_id)
|
||||
|
||||
def _on_capture_clicked(self):
|
||||
if self._lifecycle.stream_mgr:
|
||||
if not self._timeline.state.live and self._manifest:
|
||||
# Scrub mode: capture full-res from current scrub position
|
||||
self._capture_at_scrub_position()
|
||||
elif self._lifecycle.stream_mgr:
|
||||
# Live mode: capture from current recording position
|
||||
self._lifecycle.stream_mgr.capture_now(
|
||||
on_new_frames=self._lifecycle._handle_new_scene_frames
|
||||
)
|
||||
@@ -212,6 +225,7 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
|
||||
Thread(target=_compute_waveform, daemon=True, name="waveform_load").start()
|
||||
|
||||
self._update_scrub_bar_manifest()
|
||||
self._populate_model_dropdown()
|
||||
|
||||
# -- Streaming --
|
||||
@@ -241,6 +255,132 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
pos = self._monitor.get_live_position()
|
||||
self._timeline.toggle_live(live_player_pos=pos)
|
||||
|
||||
# -- Scrub --
|
||||
|
||||
def _update_scrub_bar_manifest(self):
|
||||
"""Refresh the scrub bar with the current session's segment manifest."""
|
||||
mgr = self._lifecycle.stream_mgr
|
||||
if not mgr:
|
||||
return
|
||||
self._manifest = load_segment_manifest(mgr.session_dir)
|
||||
if not self._manifest:
|
||||
self._manifest = rebuild_manifest(mgr.session_dir)
|
||||
self._timeline_controls.scrub_bar.set_manifest(self._manifest)
|
||||
|
||||
def _on_segment_activated(self, scrub_bar, segment_index):
|
||||
"""User clicked a segment block — request its proxy."""
|
||||
if not self._manifest or segment_index >= len(self._manifest):
|
||||
return
|
||||
seg = self._manifest[segment_index]
|
||||
seg_path = Path(seg["path"])
|
||||
|
||||
if not self._proxy_mgr:
|
||||
mgr = self._lifecycle.stream_mgr
|
||||
sid = mgr.session_id if mgr else "unknown"
|
||||
self._proxy_mgr = ProxyManager(sid)
|
||||
|
||||
scrub_bar.set_proxy_state(segment_index, "generating")
|
||||
# Store pending seek position (the click position)
|
||||
self._pending_scrub_global = seg["global_offset"]
|
||||
|
||||
def _on_ready(proxy_path):
|
||||
scrub_bar.set_proxy_state(segment_index, "ready")
|
||||
scrub_bar.set_active_segment(segment_index)
|
||||
self._monitor.set_scrub_source(proxy_path, global_offset=seg["global_offset"])
|
||||
# Apply pending seek now that proxy is loaded
|
||||
gt = self._pending_scrub_global
|
||||
local = gt - seg["global_offset"]
|
||||
self._timeline.state.cursor = gt
|
||||
self._timeline.state.live = False
|
||||
self._timeline.state.paused = True
|
||||
self._timeline.emit("changed")
|
||||
self._monitor.scrub_to(max(0.0, local))
|
||||
|
||||
self._proxy_mgr.request(seg_path, on_ready=_on_ready)
|
||||
|
||||
def _on_panel_seek(self, panel, timestamp):
|
||||
"""Handle seek request from frames or transcript panel (double-click)."""
|
||||
if not self._manifest:
|
||||
return
|
||||
seg, local_time = global_time_to_segment(self._manifest, timestamp)
|
||||
if not seg:
|
||||
return
|
||||
self._pending_scrub_global = timestamp
|
||||
self._on_segment_activated(self._timeline_controls.scrub_bar, seg["index"])
|
||||
|
||||
def _on_scrub_position(self, scrub_bar, global_time):
|
||||
"""User is scrubbing — drive monitor directly, throttled."""
|
||||
global_time = max(0.0, min(global_time, self._timeline.state.duration))
|
||||
self._timeline.state.cursor = global_time
|
||||
self._timeline.state.live = False
|
||||
self._timeline.state.paused = True
|
||||
# Update scrub bar cursor directly (cheap)
|
||||
scrub_bar.set_cursor(global_time)
|
||||
# Throttle monitor seeks to avoid flooding mpv
|
||||
if not self._scrub_pending:
|
||||
self._scrub_pending = True
|
||||
seg, local_time = global_time_to_segment(self._manifest, global_time)
|
||||
if seg:
|
||||
self._monitor.scrub_to(local_time)
|
||||
GLib.timeout_add(16, self._scrub_tick) # ~60fps cap
|
||||
|
||||
def _scrub_tick(self):
|
||||
"""Release throttle so next scrub motion can update monitor."""
|
||||
self._scrub_pending = False
|
||||
# Apply latest cursor position to monitor
|
||||
seg, local_time = global_time_to_segment(
|
||||
self._manifest, self._timeline.state.cursor
|
||||
)
|
||||
if seg:
|
||||
self._monitor.scrub_to(local_time)
|
||||
return False
|
||||
|
||||
def _capture_at_scrub_position(self):
|
||||
"""Capture a full-res frame at the current scrub position."""
|
||||
mgr = self._lifecycle.stream_mgr
|
||||
if not mgr or not self._manifest:
|
||||
return
|
||||
seg, local_time = global_time_to_segment(
|
||||
self._manifest, self._timeline.state.cursor
|
||||
)
|
||||
if not seg:
|
||||
return
|
||||
seg_path = Path(seg["path"])
|
||||
global_time = self._timeline.state.cursor
|
||||
|
||||
from cht.stream import ffmpeg as ff
|
||||
import json
|
||||
|
||||
def _capture():
|
||||
index_path = mgr.frames_dir / "index.json"
|
||||
index = json.loads(index_path.read_text()) if index_path.exists() else []
|
||||
frame_num = len(index) + 1
|
||||
frame_id = f"F{frame_num:04d}"
|
||||
frame_path = mgr.frames_dir / f"{frame_id}.jpg"
|
||||
|
||||
try:
|
||||
ff.extract_frame_at(seg_path, frame_path, local_time)
|
||||
except Exception as e:
|
||||
log.error("Scrub capture failed: %s", e)
|
||||
return
|
||||
if not frame_path.exists():
|
||||
return
|
||||
|
||||
entry = {
|
||||
"id": frame_id,
|
||||
"timestamp": global_time,
|
||||
"path": str(frame_path),
|
||||
"sent_to_agent": False,
|
||||
}
|
||||
index.append(entry)
|
||||
index_path.write_text(json.dumps(index, indent=2))
|
||||
log.info("Scrub capture: %s at %.1fs (local %.1fs in %s)",
|
||||
frame_id, global_time, local_time, seg_path.name)
|
||||
# Reload frames to show the new capture
|
||||
GLib.idle_add(self._load_existing_frames)
|
||||
|
||||
Thread(target=_capture, daemon=True, name="scrub_capture").start()
|
||||
|
||||
def _stop_stream(self, reload_session=False):
|
||||
log.info("Stopping stream...")
|
||||
mgr = self._lifecycle.stream_mgr
|
||||
@@ -248,7 +388,13 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
|
||||
self._lifecycle.stop()
|
||||
|
||||
if self._proxy_mgr:
|
||||
self._proxy_mgr.cancel()
|
||||
self._proxy_mgr = None
|
||||
self._manifest = []
|
||||
|
||||
self._timeline.reset()
|
||||
self._timeline_controls.scrub_bar.set_manifest([])
|
||||
self._monitor.reset()
|
||||
self._waveform_engine.reset()
|
||||
self._waveform_widget.set_peaks(None, 0.05)
|
||||
@@ -299,9 +445,11 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
top_paned.set_position(650)
|
||||
right_box.append(top_paned)
|
||||
|
||||
# Timeline slider
|
||||
# Timeline controls + scrub bar
|
||||
self._timeline_controls = TimelineControls(self._timeline)
|
||||
self._timeline_controls.set_live_toggle_callback(self._on_live_toggle)
|
||||
self._timeline_controls.scrub_bar.connect("segment-activated", self._on_segment_activated)
|
||||
self._timeline_controls.scrub_bar.connect("scrub-position", self._on_scrub_position)
|
||||
right_box.append(self._timeline_controls)
|
||||
|
||||
# Frames
|
||||
|
||||
Reference in New Issue
Block a user