diff --git a/cht/scrub/__init__.py b/cht/scrub/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cht/scrub/manager.py b/cht/scrub/manager.py new file mode 100644 index 0000000..ece8482 --- /dev/null +++ b/cht/scrub/manager.py @@ -0,0 +1,92 @@ +"""Proxy manager — background generation and lifecycle of scrub proxies.""" + +import logging +from pathlib import Path +from threading import Thread + +from gi.repository import GLib + +from cht.scrub.proxy import proxy_path_for, generate_proxy, cleanup_proxies, PROXY_HEIGHT + +log = logging.getLogger(__name__) + + +class ProxyManager: + """Manages background proxy generation for scrub mode. + + Usage: + pm = ProxyManager(session_id="20260403_120000") + pm.request(segment_path, on_ready=lambda path: ...) + pm.cancel() # stop pending work + """ + + # Proxy states + PENDING = "pending" + GENERATING = "generating" + READY = "ready" + FAILED = "failed" + + def __init__(self, session_id: str): + self._session_id = session_id + self._state: dict[str, str] = {} # segment_path_str → state + self._proxies: dict[str, Path] = {} # segment_path_str → proxy_path + self._cancelled = False + + def request(self, segment_path: Path, on_ready=None, on_error=None) -> None: + """Request proxy for a segment. Calls back on GTK main thread when ready. + + If proxy already exists, calls back immediately. + """ + key = str(segment_path) + + # Already ready + proxy = proxy_path_for(segment_path, self._session_id) + if proxy.exists(): + self._state[key] = self.READY + self._proxies[key] = proxy + if on_ready: + GLib.idle_add(on_ready, proxy) + return + + # Already generating + if self._state.get(key) == self.GENERATING: + return + + self._state[key] = self.GENERATING + + def _generate(): + if self._cancelled: + return + try: + result = generate_proxy(segment_path, proxy) + self._state[key] = self.READY + self._proxies[key] = result + if on_ready and not self._cancelled: + GLib.idle_add(on_ready, result) + except Exception as e: + self._state[key] = self.FAILED + log.error("Proxy generation failed: %s", e) + if on_error and not self._cancelled: + GLib.idle_add(on_error, str(e)) + + Thread(target=_generate, daemon=True, + name=f"proxy_{segment_path.stem}").start() + + def get_state(self, segment_path: Path) -> str | None: + """Return current state of proxy for segment, or None if not requested.""" + return self._state.get(str(segment_path)) + + def get_proxy(self, segment_path: Path) -> Path | None: + """Return proxy path if ready, None otherwise.""" + return self._proxies.get(str(segment_path)) + + def cancel(self) -> None: + """Cancel pending work. Already-running ffmpeg will finish but callbacks are suppressed.""" + self._cancelled = True + + def cleanup(self) -> None: + """Delete all proxies for this session.""" + self.cancel() + cleanup_proxies(self._session_id) + self._state.clear() + self._proxies.clear() diff --git a/cht/scrub/proxy.py b/cht/scrub/proxy.py new file mode 100644 index 0000000..aa2a356 --- /dev/null +++ b/cht/scrub/proxy.py @@ -0,0 +1,81 @@ +"""Proxy generation — low-res MJPEG for frame-accurate scrubbing. + +Each completed recording segment gets a lightweight proxy video where every +frame is a keyframe (MJPEG). mpv can seek frame-accurately in these files +with hr-seek=yes, giving DaVinci Resolve-style scrubbing speed. + +Proxies are ephemeral — stored in /tmp, regenerated on demand. +""" + +import logging +import shutil +from pathlib import Path + +import ffmpeg as ffmpeg_lib + +log = logging.getLogger(__name__) + +PROXY_DIR = Path("/tmp/cht_proxy") +PROXY_HEIGHT = 360 # pixels — low enough for speed, high enough to see content + + +def proxy_path_for(segment_path: Path, session_id: str | None = None) -> Path: + """Return the proxy path for a given segment.""" + subdir = session_id or "default" + return PROXY_DIR / subdir / f"{segment_path.stem}_proxy.avi" + + +def generate_proxy(segment_path: Path, output_path: Path, + height: int = PROXY_HEIGHT) -> Path: + """Transcode a segment to MJPEG proxy at reduced resolution. + + Every frame is a keyframe — enables O(1) seeking. + Returns output_path on success. + """ + output_path.parent.mkdir(parents=True, exist_ok=True) + + stream = ffmpeg_lib.input(str(segment_path)) + output = ( + ffmpeg_lib.output( + stream, str(output_path), + vcodec="mjpeg", + vf=f"scale=-2:{height}", + # MJPEG: every frame is a keyframe by nature + **{"q:v": "5"}, # quality 2-31, lower = better + an=None, # strip audio + ) + .overwrite_output() + .global_args("-hide_banner", "-loglevel", "warning") + ) + + log.info("Generating proxy: %s → %s", segment_path.name, output_path) + try: + output.run(capture_stdout=True, capture_stderr=True) + except ffmpeg_lib.Error as e: + stderr = (e.stderr or b"").decode("utf-8", errors="replace") + log.error("Proxy generation failed for %s: %s", segment_path.name, stderr.strip()) + raise + + log.info("Proxy ready: %s (%.1f MB)", + output_path.name, output_path.stat().st_size / 1_000_000) + return output_path + + +def ensure_proxy(segment_path: Path, session_id: str | None = None, + height: int = PROXY_HEIGHT) -> Path: + """Return proxy path, generating it if missing.""" + out = proxy_path_for(segment_path, session_id) + if out.exists(): + return out + return generate_proxy(segment_path, out, height) + + +def cleanup_proxies(session_id: str | None = None) -> None: + """Delete proxy files for a session, or all proxies if session_id is None.""" + if session_id: + target = PROXY_DIR / session_id + else: + target = PROXY_DIR + if target.exists(): + shutil.rmtree(target) + log.info("Cleaned up proxies: %s", target) diff --git a/cht/session.py b/cht/session.py index a4862b8..6e086c8 100644 --- a/cht/session.py +++ b/cht/session.py @@ -1,12 +1,102 @@ -"""Session data loading — reads frame/transcript indexes, returns plain data.""" +"""Session data loading — reads frame/transcript indexes and segment manifests.""" import json import logging from pathlib import Path +import ffmpeg as ffmpeg_lib + log = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Segment manifest — maps each recording segment to its global time offset +# --------------------------------------------------------------------------- + +def probe_duration(path: Path) -> float: + """Probe a media file's duration via ffprobe. Returns 0.0 on failure.""" + try: + info = ffmpeg_lib.probe(str(path)) + dur = float(info.get("format", {}).get("duration", 0)) + if dur > 0: + return dur + for s in info.get("streams", []): + sdur = float(s.get("duration", 0)) + if sdur > 0: + return sdur + except Exception as e: + log.debug("probe_duration failed for %s: %s", path, e) + # Fallback: rough estimate from file size (~500kbps) + try: + return path.stat().st_size / 65_000 + except Exception: + return 0.0 + + +def build_segment_manifest(stream_dir: Path) -> list[dict]: + """Probe all recording_*.mp4 in *stream_dir* and return a manifest. + + Each entry: {path, index, duration, global_offset}. + Sorted by segment index. Recomputable from files at any time. + """ + segments = sorted(stream_dir.glob("recording_*.mp4")) + manifest = [] + offset = 0.0 + for i, seg in enumerate(segments): + dur = probe_duration(seg) + manifest.append({ + "path": str(seg), + "index": i, + "duration": dur, + "global_offset": offset, + }) + offset += dur + return manifest + + +def write_segment_manifest(session_dir: Path, manifest: list[dict]) -> None: + """Write segments.json to *session_dir*.""" + path = session_dir / "segments.json" + path.write_text(json.dumps(manifest, indent=2)) + + +def load_segment_manifest(session_dir: Path) -> list[dict]: + """Read segments.json. Returns [] if missing.""" + path = session_dir / "segments.json" + if not path.exists(): + return [] + try: + return json.loads(path.read_text()) + except (json.JSONDecodeError, IOError): + return [] + + +def rebuild_manifest(session_dir: Path) -> list[dict]: + """Recalculate segment manifest from actual files and write it.""" + stream_dir = session_dir / "stream" + manifest = build_segment_manifest(stream_dir) + write_segment_manifest(session_dir, manifest) + log.info("Rebuilt manifest: %d segments, total %.1fs", + len(manifest), + sum(s["duration"] for s in manifest)) + return manifest + + +def global_time_to_segment(manifest: list[dict], global_time: float): + """Map a global timestamp to (segment_entry, local_time). + + Returns the segment containing *global_time* and the time offset + within that segment. Returns (None, 0.0) if manifest is empty. + """ + if not manifest: + return None, 0.0 + for seg in reversed(manifest): + if global_time >= seg["global_offset"]: + local = global_time - seg["global_offset"] + return seg, local + return manifest[0], global_time + + def load_frame_index(frames_dir: Path) -> list[dict]: """Read frames/index.json and return list of {id, path, timestamp}. diff --git a/cht/stream/lifecycle.py b/cht/stream/lifecycle.py index 9fe9f5a..a8439db 100644 --- a/cht/stream/lifecycle.py +++ b/cht/stream/lifecycle.py @@ -6,6 +6,7 @@ from threading import Thread from gi.repository import GLib from cht.config import TRANSCRIBE_MIN_CHUNK_S +from cht.session import rebuild_manifest from cht.stream.manager import StreamManager from cht.stream.tracker import RecordingTracker @@ -84,11 +85,19 @@ class StreamLifecycle: self._tracker = None readonly = self._stream_mgr.readonly if self._stream_mgr else True + session_dir = self._stream_mgr.session_dir if self._stream_mgr else None if self._stream_mgr: if not readonly: self._stream_mgr.stop_all() self._stream_mgr = None + # Rebuild manifest now that all segments are finalized + if session_dir and not readonly: + try: + rebuild_manifest(session_dir) + except Exception as e: + log.error("Failed to rebuild manifest on stop: %s", e) + self._streaming = False self._gone_live = False self._pending_transcript_audio.clear() @@ -129,40 +138,51 @@ class StreamLifecycle: GLib.idle_add(self._on_scene_marker, f["timestamp"]) self._on_new_frames(frames) - def _handle_new_audio(self, wav_path, start_time, duration): + def _handle_new_audio(self, wav_path, start_time, duration, + segment_path=None, local_start=None): if not self._stream_mgr: return + # start_time is global; waveform uses global time self._waveform_engine.append_chunk(wav_path, start_time) peaks = self._waveform_engine.peaks bucket_dur = self._waveform_engine.bucket_duration GLib.idle_add(self._on_waveform_update, peaks.copy(), bucket_dur) - self._pending_transcript_audio.append((wav_path, start_time, duration)) + self._pending_transcript_audio.append({ + "wav": wav_path, "global_start": start_time, "duration": duration, + "segment_path": segment_path or self._stream_mgr.recording_path, + "local_start": local_start if local_start is not None else start_time, + }) self._pending_transcript_duration += duration if self._pending_transcript_duration < TRANSCRIBE_MIN_CHUNK_S: return - first_start = self._pending_transcript_audio[0][1] + first = self._pending_transcript_audio[0] + first_global = first["global_start"] + first_local = first["local_start"] + seg_path = first["segment_path"] total_dur = self._pending_transcript_duration self._pending_transcript_audio.clear() self._pending_transcript_duration = 0.0 mgr = self._stream_mgr - chunk_wav = mgr.audio_dir / f"transcript_{int(first_start):06d}.wav" + chunk_wav = mgr.audio_dir / f"transcript_{int(first_global):06d}.wav" def _transcribe(): from cht.stream import ffmpeg as ff try: + # Extract audio using local time within the segment file ff.extract_audio_chunk( - mgr.recording_path, chunk_wav, - start_time=first_start, duration=total_dur, + seg_path, chunk_wav, + start_time=first_local, duration=total_dur, ) except Exception as e: log.error("Transcript audio extraction failed: %s", e) return if not chunk_wav.exists(): return - new_segs = self._transcriber.transcribe_chunk(chunk_wav, time_offset=first_start) + # Transcribe with global time offset so segment timestamps are global + new_segs = self._transcriber.transcribe_chunk(chunk_wav, time_offset=first_global) self._transcriber.save_index(mgr.transcript_dir / "index.json") if new_segs: GLib.idle_add(self._on_transcript_ready, new_segs) diff --git a/cht/stream/manager.py b/cht/stream/manager.py index eb192e3..adac966 100644 --- a/cht/stream/manager.py +++ b/cht/stream/manager.py @@ -65,6 +65,7 @@ class StreamManager: self._threads = {} self._stop_flags = set() self._segment = 0 + self._segment_offsets = {0: 0.0} # segment_index → global_offset self.scene_threshold = SCENE_THRESHOLD self.readonly = False # True when loaded from existing session log.info("Session: %s", session_id) @@ -72,6 +73,7 @@ class StreamManager: @classmethod def from_existing(cls, session_id): """Load an existing session without starting any ffmpeg processes.""" + from cht.session import rebuild_manifest mgr = cls(session_id=session_id) if not mgr.session_dir.exists(): raise FileNotFoundError(f"Session not found: {session_id}") @@ -80,10 +82,35 @@ class StreamManager: segments = mgr.recording_segments if segments: mgr._segment = len(segments) - 1 + mgr._rebuild_offsets() + rebuild_manifest(mgr.session_dir) log.info("Loaded existing session: %s (%d segments, %d frames)", session_id, len(segments), mgr.frame_count) return mgr + @property + def current_global_offset(self) -> float: + """Global time offset for the current recording segment.""" + return self._segment_offsets.get(self._segment, 0.0) + + def _rebuild_offsets(self): + """Compute global offsets from all segments on disk.""" + from cht.session import probe_duration + offset = 0.0 + self._segment_offsets = {} + for i, seg in enumerate(self.recording_segments): + self._segment_offsets[i] = offset + offset += probe_duration(seg) + + def _advance_segment_offset(self, completed_segment_path): + """Update offsets after a segment completes and a new one begins.""" + from cht.session import probe_duration + dur = probe_duration(completed_segment_path) + prev_offset = self._segment_offsets.get(self._segment, 0.0) + self._segment_offsets[self._segment + 1] = prev_offset + dur + log.info("Segment %d completed (%.1fs), next offset: %.1fs", + self._segment, dur, prev_offset + dur) + @property def frame_count(self): index_path = self.frames_dir / "index.json" @@ -144,6 +171,7 @@ class StreamManager: # Start after existing segments (for resumed sessions) existing = self.recording_segments self._segment = len(existing) + self._rebuild_offsets() self._launch_recorder() def restart_recorder(self): @@ -151,8 +179,11 @@ class StreamManager: old = self._procs.pop("recorder", None) if old: ff.stop_proc(old) + completed_path = self.recording_path + self._advance_segment_offset(completed_path) self._segment += 1 - log.info("Restarting recorder → segment %d", self._segment) + log.info("Restarting recorder → segment %d (offset %.1fs)", + self._segment, self.current_global_offset) self._launch_recorder() def recorder_alive(self): @@ -297,6 +328,7 @@ class StreamManager: index_path = self.frames_dir / "index.json" index = json.loads(index_path.read_text()) if index_path.exists() else [] + offset = self.current_global_offset frame_num = start_number for line in stderr.splitlines(): if "showinfo" not in line: @@ -309,7 +341,7 @@ class StreamManager: if frame_path.exists(): entry = { "id": frame_id, - "timestamp": pts_time, + "timestamp": pts_time + offset, "path": str(frame_path), "sent_to_agent": False, } @@ -332,7 +364,8 @@ class StreamManager: log.warning("capture_now: recording too short") return - timestamp = safe_duration - 1 + local_timestamp = safe_duration - 1 + timestamp = local_timestamp + self.current_global_offset index_path = self.frames_dir / "index.json" index = json.loads(index_path.read_text()) if index_path.exists() else [] frame_num = len(index) + 1 @@ -340,7 +373,7 @@ class StreamManager: frame_path = self.frames_dir / f"{frame_id}.jpg" try: - ff.extract_frame_at(self.recording_path, frame_path, timestamp) + ff.extract_frame_at(self.recording_path, frame_path, local_timestamp) except Exception as e: log.error("capture_now failed: %s", e) return @@ -421,10 +454,14 @@ class StreamManager: continue if wav_path.exists() and wav_path.stat().st_size > 100: - log.info("Audio chunk: %s (%.1fs → %.1fs)", - wav_path.name, processed_time, process_to) + global_start = processed_time + self.current_global_offset + log.info("Audio chunk: %s (%.1fs → %.1fs, global %.1fs)", + wav_path.name, processed_time, process_to, global_start) if self._on_new_audio: - self._on_new_audio(wav_path, processed_time, chunk_duration) + self._on_new_audio( + wav_path, global_start, chunk_duration, + segment_path=seg, local_start=processed_time, + ) chunk_num += 1 processed_time = process_to diff --git a/cht/ui/frames_panel.py b/cht/ui/frames_panel.py index 1d407f2..bbe607c 100644 --- a/cht/ui/frames_panel.py +++ b/cht/ui/frames_panel.py @@ -24,12 +24,14 @@ class FramesPanel(Gtk.Box): "selection-changed": (GObject.SignalFlags.RUN_FIRST, None, ()), "capture-requested": (GObject.SignalFlags.RUN_FIRST, None, ()), "threshold-changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)), + "seek-requested": (GObject.SignalFlags.RUN_FIRST, None, (float,)), } def __init__(self, **kwargs): super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0, **kwargs) self._widgets: dict[str, Gtk.Box] = {} + self._timestamps: dict[str, float] = {} self._order: list[str] = [] self._selected: str | None = None @@ -105,10 +107,11 @@ class FramesPanel(Gtk.Box): box.append(label) gesture = Gtk.GestureClick() - gesture.connect("released", lambda g, n, x, y, fid=frame_id: self.select(fid)) + gesture.connect("released", self._on_frame_click, frame_id) box.add_controller(gesture) self._widgets[frame_id] = box + self._timestamps[frame_id] = timestamp self._order.append(frame_id) self._strip.append(box) @@ -117,6 +120,12 @@ class FramesPanel(Gtk.Box): log.info("Thumbnail: %s at %.1fs", frame_id, timestamp) + def _on_frame_click(self, gesture, n_press, x, y, frame_id): + self.select(frame_id) + if n_press == 2: + ts = self._timestamps.get(frame_id, 0) + self.emit("seek-requested", ts) + def load_items(self, items: list[dict]): """Bulk load. Each dict has 'id', 'pixbuf', 'timestamp'.""" self.clear() @@ -171,6 +180,7 @@ class FramesPanel(Gtk.Box): """Remove all items and reset state.""" self._selected = None self._widgets.clear() + self._timestamps.clear() self._order.clear() while child := self._strip.get_first_child(): self._strip.remove(child) diff --git a/cht/ui/monitor.py b/cht/ui/monitor.py index b3f004b..7159dec 100644 --- a/cht/ui/monitor.py +++ b/cht/ui/monitor.py @@ -51,6 +51,8 @@ class MonitorWidget(Gtk.Box): self._live_loaded = False self._review_player = None + self._scrub_offset = 0.0 # global offset of the loaded scrub source + self._scrub_active = False # True when scrub source is loaded self._stack = Gtk.Stack() self._stack.set_hexpand(True) @@ -87,6 +89,21 @@ class MonitorWidget(Gtk.Box): self._recording_path = path log.info("Recording path: %s", path) + def set_scrub_source(self, proxy_path, global_offset=0.0): + """Load a proxy file for frame-accurate scrubbing.""" + self._recording_path = proxy_path + self._scrub_offset = global_offset + self._scrub_active = True + if self._review_player: + self._review_player.load_at(proxy_path, 0, pause=True, hr_seek=True) + self._stack.set_visible_child_name("review") + log.info("Scrub source: %s (offset %.1fs)", proxy_path, global_offset) + + def scrub_to(self, seconds): + """Seek the review player to an exact frame (for scrub bar dragging).""" + if self._review_player: + self._review_player.show_frame_at(seconds) + def get_live_position(self): """Return the live player's current time_pos, or None.""" if self._live_player: @@ -105,6 +122,8 @@ class MonitorWidget(Gtk.Box): self._live_source_url = None self._recording_path = None self._live_loaded = False + self._scrub_active = False + self._scrub_offset = 0.0 if self._live_player: self._live_player.command("stop") if self._review_player: @@ -180,6 +199,7 @@ class MonitorWidget(Gtk.Box): current = self._stack.get_visible_child_name() if s.live: + self._scrub_active = False # Ensure live player is loaded and playing if self._live_player and not self._live_loaded and self._live_source_url: self._live_player.load_live(self._live_source_url) @@ -190,17 +210,22 @@ class MonitorWidget(Gtk.Box): if current != "live": self._stack.set_visible_child_name("live") else: - # Scrub mode - if current == "live": + # Scrub / review mode + if self._scrub_active: + # Scrub mode: driven directly by scrub_to(), not by timeline + if current != "review": + self._stack.set_visible_child_name("review") + return + elif current == "live": # Transitioning from live: load MKV at cursor position atomically - pos = s.cursor # already set by toggle_live() + pos = s.cursor if self._review_player and self._recording_path: self._review_player.load_at(self._recording_path, pos, pause=s.paused) if not s.paused: self._review_player.play() self._stack.set_visible_child_name("review") else: - # Already in review: seek if cursor moved, then apply pause/play + # Already in review (non-scrub): seek if cursor moved if self._review_player: player_pos = self._review_player.time_pos or 0 if abs(s.cursor - player_pos) > 1.0: @@ -212,9 +237,12 @@ class MonitorWidget(Gtk.Box): def _sync_cursor_from_player(self): s = self._timeline.state + if self._scrub_active: + # Scrub mode: don't sync cursor from player — scrub bar drives cursor + return True if not s.live and not s.paused and self._review_player: pos = self._review_player.time_pos if pos is not None and pos > 0: self._timeline.set_cursor(pos) - # Live mode: cursor driven by tick_live() in window.py + # Live mode: cursor driven by tick_live() return True diff --git a/cht/ui/mpv.py b/cht/ui/mpv.py index 1f75513..78e7049 100644 --- a/cht/ui/mpv.py +++ b/cht/ui/mpv.py @@ -92,9 +92,11 @@ class Player: log.info("mpv load: %s", path) self._player.loadfile(str(path), mode="replace") - def load_at(self, path, seconds, pause=True): + def load_at(self, path, seconds, pause=True, hr_seek=False): """Load a file and seek to position atomically. Avoids async seek race.""" - log.info("mpv load_at: %s at %.1fs pause=%s", path, seconds, pause) + log.info("mpv load_at: %s at %.1fs pause=%s hr_seek=%s", path, seconds, pause, hr_seek) + if hr_seek: + self._player["hr-seek"] = "yes" self._player["pause"] = pause self._player.loadfile(str(path), mode="replace", start=str(seconds)) diff --git a/cht/ui/scrub_bar.py b/cht/ui/scrub_bar.py new file mode 100644 index 0000000..78e9e99 --- /dev/null +++ b/cht/ui/scrub_bar.py @@ -0,0 +1,245 @@ +"""ScrubBar: tall segmented block bar for frame-accurate scrubbing. + +Replaces the thin timeline slider with a horizontal row of blocks, +one per recording segment, proportional in width to duration. + +Click a block to activate it (trigger proxy generation). +Drag within a block to scrub frame-by-frame at mouse speed. +""" + +import logging + +import gi +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk, Gdk, GLib, GObject, Pango + +import cairo + +log = logging.getLogger(__name__) + +BAR_HEIGHT = 50 +BLOCK_GAP = 2 +BLOCK_COLOR = (0.25, 0.25, 0.30) +BLOCK_ACTIVE_COLOR = (0.35, 0.45, 0.60) +BLOCK_HOVER_COLOR = (0.30, 0.35, 0.40) +BLOCK_GENERATING_COLOR = (0.30, 0.40, 0.35) +CURSOR_COLOR = (0.9, 0.2, 0.2) +MARKER_COLOR = (0.9, 0.8, 0.2) +TEXT_COLOR = (0.8, 0.8, 0.8) + + +class ScrubBar(Gtk.DrawingArea): + """Segmented block bar for scrubbing through recording segments.""" + + __gsignals__ = { + "segment-activated": (GObject.SignalFlags.RUN_FIRST, None, (int,)), + "scrub-position": (GObject.SignalFlags.RUN_FIRST, None, (float,)), + } + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.set_content_height(BAR_HEIGHT) + self.set_hexpand(True) + + self._manifest = [] # list of {path, index, duration, global_offset} + self._total_duration = 0.0 + self._cursor = 0.0 # global cursor position + self._active_index = -1 # currently active segment index + self._hover_index = -1 # segment under mouse + self._proxy_states = {} # segment_index → "generating" | "ready" + self._scene_markers = [] # global timestamps + self._scrubbing = False + + self.set_draw_func(self._draw) + + # Mouse events + click = Gtk.GestureClick() + click.connect("pressed", self._on_pressed) + click.connect("released", self._on_released) + self.add_controller(click) + + motion = Gtk.EventControllerMotion() + motion.connect("motion", self._on_motion) + motion.connect("leave", self._on_leave) + self.add_controller(motion) + + drag = Gtk.GestureDrag() + drag.connect("drag-begin", self._on_drag_begin) + drag.connect("drag-update", self._on_drag_update) + drag.connect("drag-end", self._on_drag_end) + self.add_controller(drag) + + # -- Public API -- + + def set_manifest(self, manifest: list[dict]) -> None: + """Update the segment manifest. Triggers redraw.""" + self._manifest = manifest + self._total_duration = sum(s["duration"] for s in manifest) + self.queue_draw() + + def set_cursor(self, global_time: float) -> None: + """Update the cursor position (from Timeline).""" + self._cursor = global_time + self.queue_draw() + + def set_scene_markers(self, markers: list[float]) -> None: + """Set scene change marker positions.""" + self._scene_markers = markers + self.queue_draw() + + def set_active_segment(self, index: int) -> None: + """Set which segment is active (loaded for scrubbing).""" + self._active_index = index + self.queue_draw() + + def set_proxy_state(self, segment_index: int, state: str) -> None: + """Update proxy state for a segment ('generating', 'ready').""" + self._proxy_states[segment_index] = state + self.queue_draw() + + # -- Drawing -- + + def _draw(self, area, cr, width, height): + if not self._manifest or self._total_duration <= 0: + cr.set_source_rgb(0.15, 0.15, 0.15) + cr.rectangle(0, 0, width, height) + cr.fill() + return + + # Draw segment blocks + for seg in self._manifest: + x, w = self._segment_rect(seg, width) + idx = seg["index"] + + # Block color based on state + if idx == self._active_index: + cr.set_source_rgb(*BLOCK_ACTIVE_COLOR) + elif idx == self._hover_index: + cr.set_source_rgb(*BLOCK_HOVER_COLOR) + elif self._proxy_states.get(idx) == "generating": + cr.set_source_rgb(*BLOCK_GENERATING_COLOR) + else: + cr.set_source_rgb(*BLOCK_COLOR) + + cr.rectangle(x, 0, w, height) + cr.fill() + + # Segment label + dur = seg["duration"] + m, s = divmod(int(dur), 60) + label = f"S{idx}" if w < 40 else f"S{idx} {m}:{s:02d}" + cr.set_source_rgb(*TEXT_COLOR) + cr.set_font_size(11) + cr.move_to(x + 4, height - 6) + cr.show_text(label) + + # Proxy state indicator + state = self._proxy_states.get(idx) + if state == "ready": + cr.set_source_rgb(0.3, 0.7, 0.3) + cr.arc(x + w - 8, 8, 3, 0, 6.28) + cr.fill() + elif state == "generating": + cr.set_source_rgb(0.7, 0.7, 0.3) + cr.arc(x + w - 8, 8, 3, 0, 6.28) + cr.fill() + + # Scene markers + cr.set_source_rgb(*MARKER_COLOR) + for ts in self._scene_markers: + mx = self._global_to_x(ts, width) + if 0 <= mx <= width: + cr.rectangle(mx, 0, 1, 6) + cr.fill() + + # Cursor + cx = self._global_to_x(self._cursor, width) + if 0 <= cx <= width: + cr.set_source_rgb(*CURSOR_COLOR) + cr.rectangle(cx - 1, 0, 2, height) + cr.fill() + + # -- Geometry helpers -- + + def _segment_rect(self, seg, total_width): + """Return (x, width) for a segment block.""" + if self._total_duration <= 0: + return 0, 0 + x = (seg["global_offset"] / self._total_duration) * total_width + BLOCK_GAP / 2 + w = (seg["duration"] / self._total_duration) * total_width - BLOCK_GAP + return max(0, x), max(1, w) + + def _global_to_x(self, global_time, total_width): + """Map global time to pixel x position.""" + if self._total_duration <= 0: + return 0 + return (global_time / self._total_duration) * total_width + + def _x_to_global(self, x, total_width): + """Map pixel x to global time.""" + if total_width <= 0 or self._total_duration <= 0: + return 0.0 + return (x / total_width) * self._total_duration + + def _segment_at_x(self, x, total_width): + """Return the segment index at pixel x, or -1.""" + for seg in self._manifest: + sx, sw = self._segment_rect(seg, total_width) + if sx <= x <= sx + sw: + return seg["index"] + return -1 + + # -- Mouse handlers -- + + def _on_pressed(self, gesture, n_press, x, y): + width = self.get_width() + idx = self._segment_at_x(x, width) + if idx >= 0: + if idx != self._active_index: + # New segment — activate it (proxy will be requested) + self._active_index = idx + self.emit("segment-activated", idx) + else: + # Already active — seek to click position + gt = self._x_to_global(x, width) + self.emit("scrub-position", gt) + self.queue_draw() + + def _on_released(self, gesture, n_press, x, y): + self._scrubbing = False + + def _on_motion(self, controller, x, y): + width = self.get_width() + old_hover = self._hover_index + self._hover_index = self._segment_at_x(x, width) + if self._hover_index != old_hover: + self.queue_draw() + + # If scrubbing (dragging within active block), emit position + if self._scrubbing and self._active_index >= 0: + gt = self._x_to_global(x, width) + self.emit("scrub-position", gt) + + def _on_leave(self, controller): + if self._hover_index != -1: + self._hover_index = -1 + self.queue_draw() + + def _on_drag_begin(self, gesture, start_x, start_y): + width = self.get_width() + idx = self._segment_at_x(start_x, width) + if idx >= 0 and idx == self._active_index: + self._scrubbing = True + + def _on_drag_update(self, gesture, offset_x, offset_y): + if self._scrubbing: + ok, start_x, start_y = gesture.get_start_point() + if ok: + x = start_x + offset_x + width = self.get_width() + gt = self._x_to_global(x, width) + gt = max(0, min(gt, self._total_duration)) + self.emit("scrub-position", gt) + + def _on_drag_end(self, gesture, offset_x, offset_y): + self._scrubbing = False diff --git a/cht/ui/timeline.py b/cht/ui/timeline.py index b80ae0c..25bf4eb 100644 --- a/cht/ui/timeline.py +++ b/cht/ui/timeline.py @@ -144,54 +144,54 @@ class Timeline(GObject.Object): class TimelineControls(Gtk.Box): - """Slider + LIVE toggle. Scrub mode is always paused (seek-only, like a video editor). + """Scrub bar + time labels + LIVE toggle. - LIVE button is a toggle — active style when live=True. - Slider is insensitive in live mode. + The scrub bar shows segment blocks; labels and LIVE button sit below. """ def __init__(self, timeline, **kwargs): - super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, **kwargs) + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=2, **kwargs) self._timeline = timeline - self._dragging = False self.set_margin_start(4) self.set_margin_end(4) self.set_margin_top(2) self.set_margin_bottom(4) - # Current time label + # Scrub bar (segment blocks) + from cht.ui.scrub_bar import ScrubBar + self._scrub_bar = ScrubBar() + self.append(self._scrub_bar) + + # Bottom row: time label + duration + LIVE button + bottom = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + self._time_label = Gtk.Label(label="00:00") self._time_label.set_width_chars(6) - self.append(self._time_label) + bottom.append(self._time_label) - # Slider — disabled in live mode, scrub-seeks on release - self._slider = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL) - self._slider.set_hexpand(True) - self._slider.set_range(0, 1) - self._slider.set_draw_value(False) - self._slider.connect("value-changed", self._on_slider_value_changed) + spacer = Gtk.Box() + spacer.set_hexpand(True) + bottom.append(spacer) - press_ctrl = Gtk.GestureClick() - press_ctrl.connect("pressed", self._on_slider_pressed) - press_ctrl.connect("released", self._on_slider_released) - press_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) - self._slider.add_controller(press_ctrl) - self.append(self._slider) - - # Duration label self._duration_label = Gtk.Label(label="00:00 / 00:00") self._duration_label.set_width_chars(14) - self.append(self._duration_label) + bottom.append(self._duration_label) - # LIVE toggle button self._live_btn = Gtk.Button(label="LIVE") self._live_btn.connect("clicked", self._on_live_clicked) - self.append(self._live_btn) + bottom.append(self._live_btn) + + self.append(bottom) timeline.connect("changed", self._on_changed) GLib.timeout_add(1000, self._tick_total) + @property + def scrub_bar(self): + """Access the ScrubBar widget for signal connections.""" + return self._scrub_bar + def set_live_toggle_callback(self, cb): """Override the LIVE button handler.""" self._live_toggle_cb = cb @@ -202,32 +202,16 @@ class TimelineControls(Gtk.Box): else: self._timeline.toggle_live() - def _on_slider_value_changed(self, slider): - if self._dragging: - self._time_label.set_text(self._fmt_time(slider.get_value())) - - def _on_slider_pressed(self, gesture, n_press, x, y): - self._dragging = True - - def _on_slider_released(self, gesture, n_press, x, y): - if self._dragging: - self._dragging = False - self._timeline.seek(self._slider.get_value()) - def _on_changed(self, timeline): s = timeline.state - self._slider.set_sensitive(not s.live) - if s.live: self._live_btn.add_css_class("suggested-action") else: self._live_btn.remove_css_class("suggested-action") - if not self._dragging: - self._slider.set_range(0, max(s.duration, 0.1)) - self._slider.set_value(s.cursor) - + self._scrub_bar.set_cursor(s.cursor) + self._scrub_bar.set_scene_markers(s.scene_markers) self._time_label.set_text(self._fmt_time(s.cursor)) self._update_duration_label() diff --git a/cht/ui/transcript_panel.py b/cht/ui/transcript_panel.py index d451019..81fef34 100644 --- a/cht/ui/transcript_panel.py +++ b/cht/ui/transcript_panel.py @@ -23,6 +23,7 @@ class TranscriptPanel(Gtk.Box): "selection-changed": (GObject.SignalFlags.RUN_FIRST, None, ()), "min-chunk-changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)), "lines-per-group-changed": (GObject.SignalFlags.RUN_FIRST, None, (int,)), + "seek-requested": (GObject.SignalFlags.RUN_FIRST, None, (float,)), } def __init__(self, **kwargs): @@ -30,6 +31,7 @@ class TranscriptPanel(Gtk.Box): self._rows: dict[str, Gtk.ListBoxRow] = {} self._texts: dict[str, str] = {} + self._timestamps: dict[str, float] = {} self._order: list[str] = [] self._selected: list[str] = [] @@ -176,6 +178,7 @@ class TranscriptPanel(Gtk.Box): self._selected.clear() self._rows.clear() self._texts.clear() + self._timestamps.clear() self._order.clear() while child := self._list.get_first_child(): self._list.remove(child) @@ -186,6 +189,10 @@ class TranscriptPanel(Gtk.Box): seg_id = getattr(row, "_seg_id", None) if seg_id: self.select(seg_id) + # Double-activation (Enter key on focused row) → seek + ts = self._timestamps.get(seg_id) + if ts is not None: + self.emit("seek-requested", ts) def _make_row(self, seg): m1, s1 = divmod(int(seg.start), 60) @@ -208,6 +215,7 @@ class TranscriptPanel(Gtk.Box): self._list.append(row) self._rows[seg.id] = row self._texts[seg.id] = seg.text + self._timestamps[seg.id] = seg.start self._order.append(seg.id) def _clear_visual(self): diff --git a/cht/window.py b/cht/window.py index 5f5f5af..ca2db81 100644 --- a/cht/window.py +++ b/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