scrub optimization
This commit is contained in:
0
cht/scrub/__init__.py
Normal file
0
cht/scrub/__init__.py
Normal file
92
cht/scrub/manager.py
Normal file
92
cht/scrub/manager.py
Normal file
@@ -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()
|
||||||
81
cht/scrub/proxy.py
Normal file
81
cht/scrub/proxy.py
Normal file
@@ -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)
|
||||||
@@ -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 json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import ffmpeg as ffmpeg_lib
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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]:
|
def load_frame_index(frames_dir: Path) -> list[dict]:
|
||||||
"""Read frames/index.json and return list of {id, path, timestamp}.
|
"""Read frames/index.json and return list of {id, path, timestamp}.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from threading import Thread
|
|||||||
from gi.repository import GLib
|
from gi.repository import GLib
|
||||||
|
|
||||||
from cht.config import TRANSCRIBE_MIN_CHUNK_S
|
from cht.config import TRANSCRIBE_MIN_CHUNK_S
|
||||||
|
from cht.session import rebuild_manifest
|
||||||
from cht.stream.manager import StreamManager
|
from cht.stream.manager import StreamManager
|
||||||
from cht.stream.tracker import RecordingTracker
|
from cht.stream.tracker import RecordingTracker
|
||||||
|
|
||||||
@@ -84,11 +85,19 @@ class StreamLifecycle:
|
|||||||
self._tracker = None
|
self._tracker = None
|
||||||
|
|
||||||
readonly = self._stream_mgr.readonly if self._stream_mgr else True
|
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 self._stream_mgr:
|
||||||
if not readonly:
|
if not readonly:
|
||||||
self._stream_mgr.stop_all()
|
self._stream_mgr.stop_all()
|
||||||
self._stream_mgr = None
|
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._streaming = False
|
||||||
self._gone_live = False
|
self._gone_live = False
|
||||||
self._pending_transcript_audio.clear()
|
self._pending_transcript_audio.clear()
|
||||||
@@ -129,40 +138,51 @@ class StreamLifecycle:
|
|||||||
GLib.idle_add(self._on_scene_marker, f["timestamp"])
|
GLib.idle_add(self._on_scene_marker, f["timestamp"])
|
||||||
self._on_new_frames(frames)
|
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:
|
if not self._stream_mgr:
|
||||||
return
|
return
|
||||||
|
# start_time is global; waveform uses global time
|
||||||
self._waveform_engine.append_chunk(wav_path, start_time)
|
self._waveform_engine.append_chunk(wav_path, start_time)
|
||||||
peaks = self._waveform_engine.peaks
|
peaks = self._waveform_engine.peaks
|
||||||
bucket_dur = self._waveform_engine.bucket_duration
|
bucket_dur = self._waveform_engine.bucket_duration
|
||||||
GLib.idle_add(self._on_waveform_update, peaks.copy(), bucket_dur)
|
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
|
self._pending_transcript_duration += duration
|
||||||
if self._pending_transcript_duration < TRANSCRIBE_MIN_CHUNK_S:
|
if self._pending_transcript_duration < TRANSCRIBE_MIN_CHUNK_S:
|
||||||
return
|
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
|
total_dur = self._pending_transcript_duration
|
||||||
self._pending_transcript_audio.clear()
|
self._pending_transcript_audio.clear()
|
||||||
self._pending_transcript_duration = 0.0
|
self._pending_transcript_duration = 0.0
|
||||||
|
|
||||||
mgr = self._stream_mgr
|
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():
|
def _transcribe():
|
||||||
from cht.stream import ffmpeg as ff
|
from cht.stream import ffmpeg as ff
|
||||||
try:
|
try:
|
||||||
|
# Extract audio using local time within the segment file
|
||||||
ff.extract_audio_chunk(
|
ff.extract_audio_chunk(
|
||||||
mgr.recording_path, chunk_wav,
|
seg_path, chunk_wav,
|
||||||
start_time=first_start, duration=total_dur,
|
start_time=first_local, duration=total_dur,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Transcript audio extraction failed: %s", e)
|
log.error("Transcript audio extraction failed: %s", e)
|
||||||
return
|
return
|
||||||
if not chunk_wav.exists():
|
if not chunk_wav.exists():
|
||||||
return
|
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")
|
self._transcriber.save_index(mgr.transcript_dir / "index.json")
|
||||||
if new_segs:
|
if new_segs:
|
||||||
GLib.idle_add(self._on_transcript_ready, new_segs)
|
GLib.idle_add(self._on_transcript_ready, new_segs)
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class StreamManager:
|
|||||||
self._threads = {}
|
self._threads = {}
|
||||||
self._stop_flags = set()
|
self._stop_flags = set()
|
||||||
self._segment = 0
|
self._segment = 0
|
||||||
|
self._segment_offsets = {0: 0.0} # segment_index → global_offset
|
||||||
self.scene_threshold = SCENE_THRESHOLD
|
self.scene_threshold = SCENE_THRESHOLD
|
||||||
self.readonly = False # True when loaded from existing session
|
self.readonly = False # True when loaded from existing session
|
||||||
log.info("Session: %s", session_id)
|
log.info("Session: %s", session_id)
|
||||||
@@ -72,6 +73,7 @@ class StreamManager:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_existing(cls, session_id):
|
def from_existing(cls, session_id):
|
||||||
"""Load an existing session without starting any ffmpeg processes."""
|
"""Load an existing session without starting any ffmpeg processes."""
|
||||||
|
from cht.session import rebuild_manifest
|
||||||
mgr = cls(session_id=session_id)
|
mgr = cls(session_id=session_id)
|
||||||
if not mgr.session_dir.exists():
|
if not mgr.session_dir.exists():
|
||||||
raise FileNotFoundError(f"Session not found: {session_id}")
|
raise FileNotFoundError(f"Session not found: {session_id}")
|
||||||
@@ -80,10 +82,35 @@ class StreamManager:
|
|||||||
segments = mgr.recording_segments
|
segments = mgr.recording_segments
|
||||||
if segments:
|
if segments:
|
||||||
mgr._segment = len(segments) - 1
|
mgr._segment = len(segments) - 1
|
||||||
|
mgr._rebuild_offsets()
|
||||||
|
rebuild_manifest(mgr.session_dir)
|
||||||
log.info("Loaded existing session: %s (%d segments, %d frames)",
|
log.info("Loaded existing session: %s (%d segments, %d frames)",
|
||||||
session_id, len(segments), mgr.frame_count)
|
session_id, len(segments), mgr.frame_count)
|
||||||
return mgr
|
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
|
@property
|
||||||
def frame_count(self):
|
def frame_count(self):
|
||||||
index_path = self.frames_dir / "index.json"
|
index_path = self.frames_dir / "index.json"
|
||||||
@@ -144,6 +171,7 @@ class StreamManager:
|
|||||||
# Start after existing segments (for resumed sessions)
|
# Start after existing segments (for resumed sessions)
|
||||||
existing = self.recording_segments
|
existing = self.recording_segments
|
||||||
self._segment = len(existing)
|
self._segment = len(existing)
|
||||||
|
self._rebuild_offsets()
|
||||||
self._launch_recorder()
|
self._launch_recorder()
|
||||||
|
|
||||||
def restart_recorder(self):
|
def restart_recorder(self):
|
||||||
@@ -151,8 +179,11 @@ class StreamManager:
|
|||||||
old = self._procs.pop("recorder", None)
|
old = self._procs.pop("recorder", None)
|
||||||
if old:
|
if old:
|
||||||
ff.stop_proc(old)
|
ff.stop_proc(old)
|
||||||
|
completed_path = self.recording_path
|
||||||
|
self._advance_segment_offset(completed_path)
|
||||||
self._segment += 1
|
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()
|
self._launch_recorder()
|
||||||
|
|
||||||
def recorder_alive(self):
|
def recorder_alive(self):
|
||||||
@@ -297,6 +328,7 @@ class StreamManager:
|
|||||||
index_path = self.frames_dir / "index.json"
|
index_path = self.frames_dir / "index.json"
|
||||||
index = json.loads(index_path.read_text()) if index_path.exists() else []
|
index = json.loads(index_path.read_text()) if index_path.exists() else []
|
||||||
|
|
||||||
|
offset = self.current_global_offset
|
||||||
frame_num = start_number
|
frame_num = start_number
|
||||||
for line in stderr.splitlines():
|
for line in stderr.splitlines():
|
||||||
if "showinfo" not in line:
|
if "showinfo" not in line:
|
||||||
@@ -309,7 +341,7 @@ class StreamManager:
|
|||||||
if frame_path.exists():
|
if frame_path.exists():
|
||||||
entry = {
|
entry = {
|
||||||
"id": frame_id,
|
"id": frame_id,
|
||||||
"timestamp": pts_time,
|
"timestamp": pts_time + offset,
|
||||||
"path": str(frame_path),
|
"path": str(frame_path),
|
||||||
"sent_to_agent": False,
|
"sent_to_agent": False,
|
||||||
}
|
}
|
||||||
@@ -332,7 +364,8 @@ class StreamManager:
|
|||||||
log.warning("capture_now: recording too short")
|
log.warning("capture_now: recording too short")
|
||||||
return
|
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_path = self.frames_dir / "index.json"
|
||||||
index = json.loads(index_path.read_text()) if index_path.exists() else []
|
index = json.loads(index_path.read_text()) if index_path.exists() else []
|
||||||
frame_num = len(index) + 1
|
frame_num = len(index) + 1
|
||||||
@@ -340,7 +373,7 @@ class StreamManager:
|
|||||||
frame_path = self.frames_dir / f"{frame_id}.jpg"
|
frame_path = self.frames_dir / f"{frame_id}.jpg"
|
||||||
|
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
log.error("capture_now failed: %s", e)
|
log.error("capture_now failed: %s", e)
|
||||||
return
|
return
|
||||||
@@ -421,10 +454,14 @@ class StreamManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if wav_path.exists() and wav_path.stat().st_size > 100:
|
if wav_path.exists() and wav_path.stat().st_size > 100:
|
||||||
log.info("Audio chunk: %s (%.1fs → %.1fs)",
|
global_start = processed_time + self.current_global_offset
|
||||||
wav_path.name, processed_time, process_to)
|
log.info("Audio chunk: %s (%.1fs → %.1fs, global %.1fs)",
|
||||||
|
wav_path.name, processed_time, process_to, global_start)
|
||||||
if self._on_new_audio:
|
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
|
chunk_num += 1
|
||||||
|
|
||||||
processed_time = process_to
|
processed_time = process_to
|
||||||
|
|||||||
@@ -24,12 +24,14 @@ class FramesPanel(Gtk.Box):
|
|||||||
"selection-changed": (GObject.SignalFlags.RUN_FIRST, None, ()),
|
"selection-changed": (GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||||
"capture-requested": (GObject.SignalFlags.RUN_FIRST, None, ()),
|
"capture-requested": (GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||||
"threshold-changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)),
|
"threshold-changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)),
|
||||||
|
"seek-requested": (GObject.SignalFlags.RUN_FIRST, None, (float,)),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0, **kwargs)
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0, **kwargs)
|
||||||
|
|
||||||
self._widgets: dict[str, Gtk.Box] = {}
|
self._widgets: dict[str, Gtk.Box] = {}
|
||||||
|
self._timestamps: dict[str, float] = {}
|
||||||
self._order: list[str] = []
|
self._order: list[str] = []
|
||||||
self._selected: str | None = None
|
self._selected: str | None = None
|
||||||
|
|
||||||
@@ -105,10 +107,11 @@ class FramesPanel(Gtk.Box):
|
|||||||
box.append(label)
|
box.append(label)
|
||||||
|
|
||||||
gesture = Gtk.GestureClick()
|
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)
|
box.add_controller(gesture)
|
||||||
|
|
||||||
self._widgets[frame_id] = box
|
self._widgets[frame_id] = box
|
||||||
|
self._timestamps[frame_id] = timestamp
|
||||||
self._order.append(frame_id)
|
self._order.append(frame_id)
|
||||||
self._strip.append(box)
|
self._strip.append(box)
|
||||||
|
|
||||||
@@ -117,6 +120,12 @@ class FramesPanel(Gtk.Box):
|
|||||||
|
|
||||||
log.info("Thumbnail: %s at %.1fs", frame_id, timestamp)
|
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]):
|
def load_items(self, items: list[dict]):
|
||||||
"""Bulk load. Each dict has 'id', 'pixbuf', 'timestamp'."""
|
"""Bulk load. Each dict has 'id', 'pixbuf', 'timestamp'."""
|
||||||
self.clear()
|
self.clear()
|
||||||
@@ -171,6 +180,7 @@ class FramesPanel(Gtk.Box):
|
|||||||
"""Remove all items and reset state."""
|
"""Remove all items and reset state."""
|
||||||
self._selected = None
|
self._selected = None
|
||||||
self._widgets.clear()
|
self._widgets.clear()
|
||||||
|
self._timestamps.clear()
|
||||||
self._order.clear()
|
self._order.clear()
|
||||||
while child := self._strip.get_first_child():
|
while child := self._strip.get_first_child():
|
||||||
self._strip.remove(child)
|
self._strip.remove(child)
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ class MonitorWidget(Gtk.Box):
|
|||||||
self._live_loaded = False
|
self._live_loaded = False
|
||||||
|
|
||||||
self._review_player = None
|
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 = Gtk.Stack()
|
||||||
self._stack.set_hexpand(True)
|
self._stack.set_hexpand(True)
|
||||||
@@ -87,6 +89,21 @@ class MonitorWidget(Gtk.Box):
|
|||||||
self._recording_path = path
|
self._recording_path = path
|
||||||
log.info("Recording path: %s", 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):
|
def get_live_position(self):
|
||||||
"""Return the live player's current time_pos, or None."""
|
"""Return the live player's current time_pos, or None."""
|
||||||
if self._live_player:
|
if self._live_player:
|
||||||
@@ -105,6 +122,8 @@ class MonitorWidget(Gtk.Box):
|
|||||||
self._live_source_url = None
|
self._live_source_url = None
|
||||||
self._recording_path = None
|
self._recording_path = None
|
||||||
self._live_loaded = False
|
self._live_loaded = False
|
||||||
|
self._scrub_active = False
|
||||||
|
self._scrub_offset = 0.0
|
||||||
if self._live_player:
|
if self._live_player:
|
||||||
self._live_player.command("stop")
|
self._live_player.command("stop")
|
||||||
if self._review_player:
|
if self._review_player:
|
||||||
@@ -180,6 +199,7 @@ class MonitorWidget(Gtk.Box):
|
|||||||
current = self._stack.get_visible_child_name()
|
current = self._stack.get_visible_child_name()
|
||||||
|
|
||||||
if s.live:
|
if s.live:
|
||||||
|
self._scrub_active = False
|
||||||
# Ensure live player is loaded and playing
|
# Ensure live player is loaded and playing
|
||||||
if self._live_player and not self._live_loaded and self._live_source_url:
|
if self._live_player and not self._live_loaded and self._live_source_url:
|
||||||
self._live_player.load_live(self._live_source_url)
|
self._live_player.load_live(self._live_source_url)
|
||||||
@@ -190,17 +210,22 @@ class MonitorWidget(Gtk.Box):
|
|||||||
if current != "live":
|
if current != "live":
|
||||||
self._stack.set_visible_child_name("live")
|
self._stack.set_visible_child_name("live")
|
||||||
else:
|
else:
|
||||||
# Scrub mode
|
# Scrub / review mode
|
||||||
if current == "live":
|
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
|
# 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:
|
if self._review_player and self._recording_path:
|
||||||
self._review_player.load_at(self._recording_path, pos, pause=s.paused)
|
self._review_player.load_at(self._recording_path, pos, pause=s.paused)
|
||||||
if not s.paused:
|
if not s.paused:
|
||||||
self._review_player.play()
|
self._review_player.play()
|
||||||
self._stack.set_visible_child_name("review")
|
self._stack.set_visible_child_name("review")
|
||||||
else:
|
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:
|
if self._review_player:
|
||||||
player_pos = self._review_player.time_pos or 0
|
player_pos = self._review_player.time_pos or 0
|
||||||
if abs(s.cursor - player_pos) > 1.0:
|
if abs(s.cursor - player_pos) > 1.0:
|
||||||
@@ -212,9 +237,12 @@ class MonitorWidget(Gtk.Box):
|
|||||||
|
|
||||||
def _sync_cursor_from_player(self):
|
def _sync_cursor_from_player(self):
|
||||||
s = self._timeline.state
|
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:
|
if not s.live and not s.paused and self._review_player:
|
||||||
pos = self._review_player.time_pos
|
pos = self._review_player.time_pos
|
||||||
if pos is not None and pos > 0:
|
if pos is not None and pos > 0:
|
||||||
self._timeline.set_cursor(pos)
|
self._timeline.set_cursor(pos)
|
||||||
# Live mode: cursor driven by tick_live() in window.py
|
# Live mode: cursor driven by tick_live()
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -92,9 +92,11 @@ class Player:
|
|||||||
log.info("mpv load: %s", path)
|
log.info("mpv load: %s", path)
|
||||||
self._player.loadfile(str(path), mode="replace")
|
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."""
|
"""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["pause"] = pause
|
||||||
self._player.loadfile(str(path), mode="replace", start=str(seconds))
|
self._player.loadfile(str(path), mode="replace", start=str(seconds))
|
||||||
|
|
||||||
|
|||||||
245
cht/ui/scrub_bar.py
Normal file
245
cht/ui/scrub_bar.py
Normal file
@@ -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
|
||||||
@@ -144,54 +144,54 @@ class Timeline(GObject.Object):
|
|||||||
|
|
||||||
|
|
||||||
class TimelineControls(Gtk.Box):
|
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.
|
The scrub bar shows segment blocks; labels and LIVE button sit below.
|
||||||
Slider is insensitive in live mode.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, timeline, **kwargs):
|
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._timeline = timeline
|
||||||
self._dragging = False
|
|
||||||
|
|
||||||
self.set_margin_start(4)
|
self.set_margin_start(4)
|
||||||
self.set_margin_end(4)
|
self.set_margin_end(4)
|
||||||
self.set_margin_top(2)
|
self.set_margin_top(2)
|
||||||
self.set_margin_bottom(4)
|
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 = Gtk.Label(label="00:00")
|
||||||
self._time_label.set_width_chars(6)
|
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
|
spacer = Gtk.Box()
|
||||||
self._slider = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL)
|
spacer.set_hexpand(True)
|
||||||
self._slider.set_hexpand(True)
|
bottom.append(spacer)
|
||||||
self._slider.set_range(0, 1)
|
|
||||||
self._slider.set_draw_value(False)
|
|
||||||
self._slider.connect("value-changed", self._on_slider_value_changed)
|
|
||||||
|
|
||||||
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 = Gtk.Label(label="00:00 / 00:00")
|
||||||
self._duration_label.set_width_chars(14)
|
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 = Gtk.Button(label="LIVE")
|
||||||
self._live_btn.connect("clicked", self._on_live_clicked)
|
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)
|
timeline.connect("changed", self._on_changed)
|
||||||
GLib.timeout_add(1000, self._tick_total)
|
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):
|
def set_live_toggle_callback(self, cb):
|
||||||
"""Override the LIVE button handler."""
|
"""Override the LIVE button handler."""
|
||||||
self._live_toggle_cb = cb
|
self._live_toggle_cb = cb
|
||||||
@@ -202,32 +202,16 @@ class TimelineControls(Gtk.Box):
|
|||||||
else:
|
else:
|
||||||
self._timeline.toggle_live()
|
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):
|
def _on_changed(self, timeline):
|
||||||
s = timeline.state
|
s = timeline.state
|
||||||
|
|
||||||
self._slider.set_sensitive(not s.live)
|
|
||||||
|
|
||||||
if s.live:
|
if s.live:
|
||||||
self._live_btn.add_css_class("suggested-action")
|
self._live_btn.add_css_class("suggested-action")
|
||||||
else:
|
else:
|
||||||
self._live_btn.remove_css_class("suggested-action")
|
self._live_btn.remove_css_class("suggested-action")
|
||||||
|
|
||||||
if not self._dragging:
|
self._scrub_bar.set_cursor(s.cursor)
|
||||||
self._slider.set_range(0, max(s.duration, 0.1))
|
self._scrub_bar.set_scene_markers(s.scene_markers)
|
||||||
self._slider.set_value(s.cursor)
|
|
||||||
|
|
||||||
self._time_label.set_text(self._fmt_time(s.cursor))
|
self._time_label.set_text(self._fmt_time(s.cursor))
|
||||||
self._update_duration_label()
|
self._update_duration_label()
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class TranscriptPanel(Gtk.Box):
|
|||||||
"selection-changed": (GObject.SignalFlags.RUN_FIRST, None, ()),
|
"selection-changed": (GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||||
"min-chunk-changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)),
|
"min-chunk-changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)),
|
||||||
"lines-per-group-changed": (GObject.SignalFlags.RUN_FIRST, None, (int,)),
|
"lines-per-group-changed": (GObject.SignalFlags.RUN_FIRST, None, (int,)),
|
||||||
|
"seek-requested": (GObject.SignalFlags.RUN_FIRST, None, (float,)),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
@@ -30,6 +31,7 @@ class TranscriptPanel(Gtk.Box):
|
|||||||
|
|
||||||
self._rows: dict[str, Gtk.ListBoxRow] = {}
|
self._rows: dict[str, Gtk.ListBoxRow] = {}
|
||||||
self._texts: dict[str, str] = {}
|
self._texts: dict[str, str] = {}
|
||||||
|
self._timestamps: dict[str, float] = {}
|
||||||
self._order: list[str] = []
|
self._order: list[str] = []
|
||||||
self._selected: list[str] = []
|
self._selected: list[str] = []
|
||||||
|
|
||||||
@@ -176,6 +178,7 @@ class TranscriptPanel(Gtk.Box):
|
|||||||
self._selected.clear()
|
self._selected.clear()
|
||||||
self._rows.clear()
|
self._rows.clear()
|
||||||
self._texts.clear()
|
self._texts.clear()
|
||||||
|
self._timestamps.clear()
|
||||||
self._order.clear()
|
self._order.clear()
|
||||||
while child := self._list.get_first_child():
|
while child := self._list.get_first_child():
|
||||||
self._list.remove(child)
|
self._list.remove(child)
|
||||||
@@ -186,6 +189,10 @@ class TranscriptPanel(Gtk.Box):
|
|||||||
seg_id = getattr(row, "_seg_id", None)
|
seg_id = getattr(row, "_seg_id", None)
|
||||||
if seg_id:
|
if seg_id:
|
||||||
self.select(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):
|
def _make_row(self, seg):
|
||||||
m1, s1 = divmod(int(seg.start), 60)
|
m1, s1 = divmod(int(seg.start), 60)
|
||||||
@@ -208,6 +215,7 @@ class TranscriptPanel(Gtk.Box):
|
|||||||
self._list.append(row)
|
self._list.append(row)
|
||||||
self._rows[seg.id] = row
|
self._rows[seg.id] = row
|
||||||
self._texts[seg.id] = seg.text
|
self._texts[seg.id] = seg.text
|
||||||
|
self._timestamps[seg.id] = seg.start
|
||||||
self._order.append(seg.id)
|
self._order.append(seg.id)
|
||||||
|
|
||||||
def _clear_visual(self):
|
def _clear_visual(self):
|
||||||
|
|||||||
154
cht/window.py
154
cht/window.py
@@ -1,6 +1,7 @@
|
|||||||
"""Main application window — wires Timeline to all components."""
|
"""Main application window — wires Timeline to all components."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
gi.require_version("Gtk", "4.0")
|
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.manager import StreamManager, list_sessions
|
||||||
from cht.stream.lifecycle import StreamLifecycle
|
from cht.stream.lifecycle import StreamLifecycle
|
||||||
from cht.ui.session_dialog import SessionDialog
|
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
|
from cht.agent.runner import AgentRunner, check_claude_cli
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -36,6 +38,10 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self.set_title(APP_NAME)
|
self.set_title(APP_NAME)
|
||||||
self.set_default_size(1400, 900)
|
self.set_default_size(1400, 900)
|
||||||
self._known_frames = set()
|
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
|
# Core components
|
||||||
self._timeline = Timeline()
|
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("selection-changed", self._on_transcript_selection_changed)
|
||||||
self._transcript_panel.connect("min-chunk-changed", self._on_min_chunk_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)
|
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")
|
log.info("Window initialized")
|
||||||
GLib.idle_add(self._check_agent_auth)
|
GLib.idle_add(self._check_agent_auth)
|
||||||
@@ -129,7 +138,11 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._start_stream(session_id=session_id)
|
self._start_stream(session_id=session_id)
|
||||||
|
|
||||||
def _on_capture_clicked(self):
|
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(
|
self._lifecycle.stream_mgr.capture_now(
|
||||||
on_new_frames=self._lifecycle._handle_new_scene_frames
|
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()
|
Thread(target=_compute_waveform, daemon=True, name="waveform_load").start()
|
||||||
|
|
||||||
|
self._update_scrub_bar_manifest()
|
||||||
self._populate_model_dropdown()
|
self._populate_model_dropdown()
|
||||||
|
|
||||||
# -- Streaming --
|
# -- Streaming --
|
||||||
@@ -241,6 +255,132 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
pos = self._monitor.get_live_position()
|
pos = self._monitor.get_live_position()
|
||||||
self._timeline.toggle_live(live_player_pos=pos)
|
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):
|
def _stop_stream(self, reload_session=False):
|
||||||
log.info("Stopping stream...")
|
log.info("Stopping stream...")
|
||||||
mgr = self._lifecycle.stream_mgr
|
mgr = self._lifecycle.stream_mgr
|
||||||
@@ -248,7 +388,13 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
self._lifecycle.stop()
|
self._lifecycle.stop()
|
||||||
|
|
||||||
|
if self._proxy_mgr:
|
||||||
|
self._proxy_mgr.cancel()
|
||||||
|
self._proxy_mgr = None
|
||||||
|
self._manifest = []
|
||||||
|
|
||||||
self._timeline.reset()
|
self._timeline.reset()
|
||||||
|
self._timeline_controls.scrub_bar.set_manifest([])
|
||||||
self._monitor.reset()
|
self._monitor.reset()
|
||||||
self._waveform_engine.reset()
|
self._waveform_engine.reset()
|
||||||
self._waveform_widget.set_peaks(None, 0.05)
|
self._waveform_widget.set_peaks(None, 0.05)
|
||||||
@@ -299,9 +445,11 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
top_paned.set_position(650)
|
top_paned.set_position(650)
|
||||||
right_box.append(top_paned)
|
right_box.append(top_paned)
|
||||||
|
|
||||||
# Timeline slider
|
# Timeline controls + scrub bar
|
||||||
self._timeline_controls = TimelineControls(self._timeline)
|
self._timeline_controls = TimelineControls(self._timeline)
|
||||||
self._timeline_controls.set_live_toggle_callback(self._on_live_toggle)
|
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)
|
right_box.append(self._timeline_controls)
|
||||||
|
|
||||||
# Frames
|
# Frames
|
||||||
|
|||||||
Reference in New Issue
Block a user