very nice
This commit is contained in:
@@ -43,7 +43,9 @@ class ChtApp(Adw.Application):
|
|||||||
css = Gtk.CssProvider()
|
css = Gtk.CssProvider()
|
||||||
css.load_from_string(
|
css.load_from_string(
|
||||||
".frame-selected { outline: 3px solid @accent_color; outline-offset: -3px; border-radius: 6px; }\n"
|
".frame-selected { outline: 3px solid @accent_color; outline-offset: -3px; border-radius: 6px; }\n"
|
||||||
"row.frame-selected, row.frame-selected:hover { background: alpha(@accent_color, 0.25); outline: none; border-radius: 0; }"
|
"row.frame-selected, row.frame-selected:hover { background: alpha(@accent_color, 0.25); outline: none; border-radius: 0; }\n"
|
||||||
|
".frame-highlight { outline: 2px solid @warning_color; outline-offset: -2px; border-radius: 4px; opacity: 0.9; }\n"
|
||||||
|
"row.frame-highlight { background: alpha(@warning_color, 0.15); }"
|
||||||
)
|
)
|
||||||
Gtk.StyleContext.add_provider_for_display(
|
Gtk.StyleContext.add_provider_for_display(
|
||||||
Gdk.Display.get_default(),
|
Gdk.Display.get_default(),
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ class StreamLifecycle:
|
|||||||
|
|
||||||
def __init__(self, *, timeline, waveform_engine, transcriber,
|
def __init__(self, *, timeline, waveform_engine, transcriber,
|
||||||
on_new_frames, on_waveform_update, on_transcript_ready,
|
on_new_frames, on_waveform_update, on_transcript_ready,
|
||||||
on_scene_marker, on_recorder_restarted):
|
on_scene_marker, on_recorder_restarted,
|
||||||
|
on_manifest_updated=None):
|
||||||
self._timeline = timeline
|
self._timeline = timeline
|
||||||
self._waveform_engine = waveform_engine
|
self._waveform_engine = waveform_engine
|
||||||
self._transcriber = transcriber
|
self._transcriber = transcriber
|
||||||
@@ -34,6 +35,7 @@ class StreamLifecycle:
|
|||||||
self._on_transcript_ready = on_transcript_ready
|
self._on_transcript_ready = on_transcript_ready
|
||||||
self._on_scene_marker = on_scene_marker
|
self._on_scene_marker = on_scene_marker
|
||||||
self._on_recorder_restarted = on_recorder_restarted
|
self._on_recorder_restarted = on_recorder_restarted
|
||||||
|
self._on_manifest_updated = on_manifest_updated
|
||||||
|
|
||||||
# State
|
# State
|
||||||
self._streaming = False
|
self._streaming = False
|
||||||
@@ -196,4 +198,11 @@ class StreamLifecycle:
|
|||||||
log.warning("Recorder died — restarting into new segment")
|
log.warning("Recorder died — restarting into new segment")
|
||||||
self._stream_mgr.restart_recorder()
|
self._stream_mgr.restart_recorder()
|
||||||
self._on_recorder_restarted(self._stream_mgr.recording_path)
|
self._on_recorder_restarted(self._stream_mgr.recording_path)
|
||||||
|
# Rebuild manifest with the newly completed segment
|
||||||
|
try:
|
||||||
|
rebuild_manifest(self._stream_mgr.session_dir)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Manifest rebuild failed: %s", e)
|
||||||
|
if self._on_manifest_updated:
|
||||||
|
GLib.idle_add(self._on_manifest_updated)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -176,6 +176,24 @@ class FramesPanel(Gtk.Box):
|
|||||||
self._selected = None
|
self._selected = None
|
||||||
self.emit("selection-changed")
|
self.emit("selection-changed")
|
||||||
|
|
||||||
|
def highlight_nearest(self, timestamp: float) -> None:
|
||||||
|
"""Scroll to and briefly highlight the frame closest to *timestamp*."""
|
||||||
|
if not self._order:
|
||||||
|
return
|
||||||
|
best_id = None
|
||||||
|
best_dist = float("inf")
|
||||||
|
for fid, ts in self._timestamps.items():
|
||||||
|
d = abs(ts - timestamp)
|
||||||
|
if d < best_dist:
|
||||||
|
best_dist = d
|
||||||
|
best_id = fid
|
||||||
|
if not best_id or best_id not in self._widgets:
|
||||||
|
return
|
||||||
|
widget = self._widgets[best_id]
|
||||||
|
widget.add_css_class("frame-highlight")
|
||||||
|
self._scroll_to(widget)
|
||||||
|
GLib.timeout_add(400, lambda: widget.remove_css_class("frame-highlight") or False)
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
"""Remove all items and reset state."""
|
"""Remove all items and reset state."""
|
||||||
self._selected = None
|
self._selected = None
|
||||||
|
|||||||
@@ -217,12 +217,8 @@ class MonitorWidget(Gtk.Box):
|
|||||||
self._stack.set_visible_child_name("review")
|
self._stack.set_visible_child_name("review")
|
||||||
return
|
return
|
||||||
elif current == "live":
|
elif current == "live":
|
||||||
# Transitioning from live: load MKV at cursor position atomically
|
# Transitioning from live to scrub: just switch stack.
|
||||||
pos = s.cursor
|
# Don't auto-load the growing MKV — user picks a segment via scrub bar.
|
||||||
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")
|
self._stack.set_visible_child_name("review")
|
||||||
else:
|
else:
|
||||||
# Already in review (non-scrub): seek if cursor moved
|
# Already in review (non-scrub): seek if cursor moved
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ Drag within a block to scrub frame-by-frame at mouse speed.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
gi.require_version("Gtk", "4.0")
|
gi.require_version("Gtk", "4.0")
|
||||||
from gi.repository import Gtk, Gdk, GLib, GObject, Pango
|
gi.require_version("GdkPixbuf", "2.0")
|
||||||
|
from gi.repository import Gtk, Gdk, GLib, GObject, GdkPixbuf
|
||||||
|
|
||||||
import cairo
|
import cairo
|
||||||
|
|
||||||
@@ -49,6 +52,7 @@ class ScrubBar(Gtk.DrawingArea):
|
|||||||
self._proxy_states = {} # segment_index → "generating" | "ready"
|
self._proxy_states = {} # segment_index → "generating" | "ready"
|
||||||
self._scene_markers = [] # global timestamps
|
self._scene_markers = [] # global timestamps
|
||||||
self._scrubbing = False
|
self._scrubbing = False
|
||||||
|
self._frame_thumbs = [] # list of {timestamp, surface} — cairo surfaces
|
||||||
|
|
||||||
self.set_draw_func(self._draw)
|
self.set_draw_func(self._draw)
|
||||||
|
|
||||||
@@ -77,6 +81,12 @@ class ScrubBar(Gtk.DrawingArea):
|
|||||||
self._total_duration = sum(s["duration"] for s in manifest)
|
self._total_duration = sum(s["duration"] for s in manifest)
|
||||||
self.queue_draw()
|
self.queue_draw()
|
||||||
|
|
||||||
|
def set_duration(self, duration: float) -> None:
|
||||||
|
"""Update total duration (from Timeline, overrides manifest sum if larger)."""
|
||||||
|
if duration > self._total_duration:
|
||||||
|
self._total_duration = duration
|
||||||
|
self.queue_draw()
|
||||||
|
|
||||||
def set_cursor(self, global_time: float) -> None:
|
def set_cursor(self, global_time: float) -> None:
|
||||||
"""Update the cursor position (from Timeline)."""
|
"""Update the cursor position (from Timeline)."""
|
||||||
self._cursor = global_time
|
self._cursor = global_time
|
||||||
@@ -97,6 +107,66 @@ class ScrubBar(Gtk.DrawingArea):
|
|||||||
self._proxy_states[segment_index] = state
|
self._proxy_states[segment_index] = state
|
||||||
self.queue_draw()
|
self.queue_draw()
|
||||||
|
|
||||||
|
def set_frames(self, frames: list[dict]) -> None:
|
||||||
|
"""Set frame thumbnails. Each dict: {timestamp, path}.
|
||||||
|
|
||||||
|
Loads thumbnails scaled to fit the bar height and caches as cairo surfaces.
|
||||||
|
"""
|
||||||
|
self._frame_thumbs = []
|
||||||
|
thumb_h = BAR_HEIGHT - 4 # 2px margin top/bottom
|
||||||
|
thumb_w = int(thumb_h * 16 / 9) # assume 16:9 aspect
|
||||||
|
for f in frames:
|
||||||
|
path = f.get("path")
|
||||||
|
ts = f.get("timestamp", 0)
|
||||||
|
if not path or not Path(path).exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||||
|
str(path), thumb_w, thumb_h, True
|
||||||
|
)
|
||||||
|
surface = self._pixbuf_to_surface(pixbuf)
|
||||||
|
self._frame_thumbs.append({
|
||||||
|
"timestamp": ts,
|
||||||
|
"surface": surface,
|
||||||
|
"width": pixbuf.get_width(),
|
||||||
|
"height": pixbuf.get_height(),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("Thumb load failed for %s: %s", path, e)
|
||||||
|
self.queue_draw()
|
||||||
|
|
||||||
|
def add_frame(self, timestamp: float, path: str) -> None:
|
||||||
|
"""Add a single frame thumbnail incrementally."""
|
||||||
|
if not Path(path).exists():
|
||||||
|
return
|
||||||
|
thumb_h = BAR_HEIGHT - 4
|
||||||
|
thumb_w = int(thumb_h * 16 / 9)
|
||||||
|
try:
|
||||||
|
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, thumb_w, thumb_h, True)
|
||||||
|
surface = self._pixbuf_to_surface(pixbuf)
|
||||||
|
self._frame_thumbs.append({
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"surface": surface,
|
||||||
|
"width": pixbuf.get_width(),
|
||||||
|
"height": pixbuf.get_height(),
|
||||||
|
})
|
||||||
|
self.queue_draw()
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("Thumb load failed for %s: %s", path, e)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pixbuf_to_surface(pixbuf):
|
||||||
|
"""Convert a GdkPixbuf to a cairo ImageSurface."""
|
||||||
|
w, h = pixbuf.get_width(), pixbuf.get_height()
|
||||||
|
has_alpha = pixbuf.get_has_alpha()
|
||||||
|
fmt = cairo.FORMAT_ARGB32 if has_alpha else cairo.FORMAT_RGB24
|
||||||
|
surface = cairo.ImageSurface(fmt, w, h)
|
||||||
|
cr = cairo.Context(surface)
|
||||||
|
Gdk.cairo_set_source_pixbuf(cr, pixbuf, 0, 0)
|
||||||
|
cr.paint()
|
||||||
|
surface.flush()
|
||||||
|
return surface
|
||||||
|
|
||||||
# -- Drawing --
|
# -- Drawing --
|
||||||
|
|
||||||
def _draw(self, area, cr, width, height):
|
def _draw(self, area, cr, width, height):
|
||||||
@@ -144,7 +214,31 @@ class ScrubBar(Gtk.DrawingArea):
|
|||||||
cr.arc(x + w - 8, 8, 3, 0, 6.28)
|
cr.arc(x + w - 8, 8, 3, 0, 6.28)
|
||||||
cr.fill()
|
cr.fill()
|
||||||
|
|
||||||
# Scene markers
|
# Frame thumbnails at their timestamp positions
|
||||||
|
for thumb in self._frame_thumbs:
|
||||||
|
tx = self._global_to_x(thumb["timestamp"], width)
|
||||||
|
tw, th = thumb["width"], thumb["height"]
|
||||||
|
# Center thumbnail on its timestamp, vertically centered in bar
|
||||||
|
x0 = tx - tw / 2
|
||||||
|
y0 = (height - th) / 2
|
||||||
|
cr.save()
|
||||||
|
# Dark outline for separation against any background
|
||||||
|
cr.set_source_rgba(0, 0, 0, 0.7)
|
||||||
|
cr.set_line_width(2)
|
||||||
|
cr.rectangle(x0 - 1, y0 - 1, tw + 2, th + 2)
|
||||||
|
cr.stroke()
|
||||||
|
# Thumbnail
|
||||||
|
cr.set_source_surface(thumb["surface"], x0, y0)
|
||||||
|
cr.rectangle(x0, y0, tw, th)
|
||||||
|
cr.fill()
|
||||||
|
# Bright inner border
|
||||||
|
cr.set_source_rgba(1, 1, 1, 0.5)
|
||||||
|
cr.set_line_width(1)
|
||||||
|
cr.rectangle(x0, y0, tw, th)
|
||||||
|
cr.stroke()
|
||||||
|
cr.restore()
|
||||||
|
|
||||||
|
# Scene markers (on top of thumbs)
|
||||||
cr.set_source_rgb(*MARKER_COLOR)
|
cr.set_source_rgb(*MARKER_COLOR)
|
||||||
for ts in self._scene_markers:
|
for ts in self._scene_markers:
|
||||||
mx = self._global_to_x(ts, width)
|
mx = self._global_to_x(ts, width)
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ class TimelineControls(Gtk.Box):
|
|||||||
else:
|
else:
|
||||||
self._live_btn.remove_css_class("suggested-action")
|
self._live_btn.remove_css_class("suggested-action")
|
||||||
|
|
||||||
|
self._scrub_bar.set_duration(s.duration)
|
||||||
self._scrub_bar.set_cursor(s.cursor)
|
self._scrub_bar.set_cursor(s.cursor)
|
||||||
self._scrub_bar.set_scene_markers(s.scene_markers)
|
self._scrub_bar.set_scene_markers(s.scene_markers)
|
||||||
self._time_label.set_text(self._fmt_time(s.cursor))
|
self._time_label.set_text(self._fmt_time(s.cursor))
|
||||||
|
|||||||
@@ -173,6 +173,24 @@ class TranscriptPanel(Gtk.Box):
|
|||||||
self._selected.clear()
|
self._selected.clear()
|
||||||
self.emit("selection-changed")
|
self.emit("selection-changed")
|
||||||
|
|
||||||
|
def highlight_nearest(self, timestamp: float) -> None:
|
||||||
|
"""Scroll to and briefly highlight the transcript segment closest to *timestamp*."""
|
||||||
|
if not self._order:
|
||||||
|
return
|
||||||
|
best_id = None
|
||||||
|
best_dist = float("inf")
|
||||||
|
for sid, ts in self._timestamps.items():
|
||||||
|
d = abs(ts - timestamp)
|
||||||
|
if d < best_dist:
|
||||||
|
best_dist = d
|
||||||
|
best_id = sid
|
||||||
|
if not best_id or best_id not in self._rows:
|
||||||
|
return
|
||||||
|
row = self._rows[best_id]
|
||||||
|
row.add_css_class("frame-highlight")
|
||||||
|
self._scroll_to_row(row)
|
||||||
|
GLib.timeout_add(400, lambda: row.remove_css_class("frame-highlight") or False)
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
"""Remove all items and reset state."""
|
"""Remove all items and reset state."""
|
||||||
self._selected.clear()
|
self._selected.clear()
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
on_transcript_ready=lambda segs: self._transcript_panel.add_items(segs),
|
on_transcript_ready=lambda segs: self._transcript_panel.add_items(segs),
|
||||||
on_scene_marker=lambda ts: self._timeline.add_scene_marker(ts),
|
on_scene_marker=lambda ts: self._timeline.add_scene_marker(ts),
|
||||||
on_recorder_restarted=lambda path: self._monitor.set_recording(path),
|
on_recorder_restarted=lambda path: self._monitor.set_recording(path),
|
||||||
|
on_manifest_updated=lambda: self._update_scrub_bar_manifest(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Panels (own their selection state)
|
# Panels (own their selection state)
|
||||||
@@ -252,20 +253,33 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
log.info("Waiting for sender...")
|
log.info("Waiting for sender...")
|
||||||
|
|
||||||
def _on_live_toggle(self):
|
def _on_live_toggle(self):
|
||||||
pos = self._monitor.get_live_position()
|
if self._timeline.state.live:
|
||||||
self._timeline.toggle_live(live_player_pos=pos)
|
# Live → Scrub: don't load growing MKV, let user pick a segment
|
||||||
|
self._timeline.toggle_live(live_player_pos=self._monitor.get_live_position())
|
||||||
|
# Refresh manifest so scrub bar shows completed segments
|
||||||
|
self._update_scrub_bar_manifest()
|
||||||
|
else:
|
||||||
|
# Scrub → Live: restore recording path and resume
|
||||||
|
mgr = self._lifecycle.stream_mgr
|
||||||
|
if mgr:
|
||||||
|
self._monitor.set_recording(mgr.recording_path)
|
||||||
|
self._timeline.toggle_live()
|
||||||
|
|
||||||
# -- Scrub --
|
# -- Scrub --
|
||||||
|
|
||||||
def _update_scrub_bar_manifest(self):
|
def _update_scrub_bar_manifest(self):
|
||||||
"""Refresh the scrub bar with the current session's segment manifest."""
|
"""Refresh the scrub bar with the current session's segment manifest and frames."""
|
||||||
mgr = self._lifecycle.stream_mgr
|
mgr = self._lifecycle.stream_mgr
|
||||||
if not mgr:
|
if not mgr:
|
||||||
return
|
return
|
||||||
self._manifest = load_segment_manifest(mgr.session_dir)
|
self._manifest = load_segment_manifest(mgr.session_dir)
|
||||||
if not self._manifest:
|
if not self._manifest:
|
||||||
self._manifest = rebuild_manifest(mgr.session_dir)
|
self._manifest = rebuild_manifest(mgr.session_dir)
|
||||||
self._timeline_controls.scrub_bar.set_manifest(self._manifest)
|
scrub_bar = self._timeline_controls.scrub_bar
|
||||||
|
scrub_bar.set_manifest(self._manifest)
|
||||||
|
# Feed frame thumbnails to the scrub bar
|
||||||
|
frames = load_frame_index(mgr.frames_dir)
|
||||||
|
scrub_bar.set_frames([{"timestamp": f["timestamp"], "path": str(f["path"])} for f in frames])
|
||||||
|
|
||||||
def _on_segment_activated(self, scrub_bar, segment_index):
|
def _on_segment_activated(self, scrub_bar, segment_index):
|
||||||
"""User clicked a segment block — request its proxy."""
|
"""User clicked a segment block — request its proxy."""
|
||||||
@@ -327,12 +341,16 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
def _scrub_tick(self):
|
def _scrub_tick(self):
|
||||||
"""Release throttle so next scrub motion can update monitor."""
|
"""Release throttle so next scrub motion can update monitor."""
|
||||||
self._scrub_pending = False
|
self._scrub_pending = False
|
||||||
|
cursor = self._timeline.state.cursor
|
||||||
# Apply latest cursor position to monitor
|
# Apply latest cursor position to monitor
|
||||||
seg, local_time = global_time_to_segment(
|
seg, local_time = global_time_to_segment(self._manifest, cursor)
|
||||||
self._manifest, self._timeline.state.cursor
|
|
||||||
)
|
|
||||||
if seg:
|
if seg:
|
||||||
self._monitor.scrub_to(local_time)
|
self._monitor.scrub_to(local_time)
|
||||||
|
# Sync waveform, time labels, etc. at throttled rate
|
||||||
|
self._timeline.emit("changed")
|
||||||
|
# Highlight nearest frame/transcript
|
||||||
|
self._frames_panel.highlight_nearest(cursor)
|
||||||
|
self._transcript_panel.highlight_nearest(cursor)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _capture_at_scrub_position(self):
|
def _capture_at_scrub_position(self):
|
||||||
@@ -583,6 +601,10 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._frames_panel.load_items(items)
|
self._frames_panel.load_items(items)
|
||||||
self._known_frames = {item["id"] for item in items}
|
self._known_frames = {item["id"] for item in items}
|
||||||
self._agent_output.append(f" Loaded {len(items)} frame thumbnails.\n")
|
self._agent_output.append(f" Loaded {len(items)} frame thumbnails.\n")
|
||||||
|
# Update scrub bar thumbnails
|
||||||
|
self._timeline_controls.scrub_bar.set_frames(
|
||||||
|
[{"timestamp": e["timestamp"], "path": str(e["path"])} for e in entries]
|
||||||
|
)
|
||||||
|
|
||||||
def _load_existing_transcript(self):
|
def _load_existing_transcript(self):
|
||||||
if not self._lifecycle.stream_mgr:
|
if not self._lifecycle.stream_mgr:
|
||||||
@@ -608,6 +630,7 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(entry["path"]), 256, 144, True)
|
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(entry["path"]), 256, 144, True)
|
||||||
auto = not self._transcript_panel.has_selection
|
auto = not self._transcript_panel.has_selection
|
||||||
self._frames_panel.add_item(fid, pixbuf, entry["timestamp"], auto_select=auto)
|
self._frames_panel.add_item(fid, pixbuf, entry["timestamp"], auto_select=auto)
|
||||||
|
self._timeline_controls.scrub_bar.add_frame(entry["timestamp"], str(entry["path"]))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("Thumbnail load failed for %s: %s", fid, e)
|
log.warning("Thumbnail load failed for %s: %s", fid, e)
|
||||||
return True
|
return True
|
||||||
|
|||||||
Reference in New Issue
Block a user