scrub optimization

This commit is contained in:
2026-04-03 06:40:08 -03:00
parent 9dfa252727
commit 84dc1405dc
13 changed files with 813 additions and 68 deletions

View File

@@ -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