ffmpeg fix

This commit is contained in:
2026-04-03 09:28:03 -03:00
parent 36e8358fc9
commit 3f76670169
5 changed files with 138 additions and 19 deletions

View File

@@ -15,7 +15,9 @@ import ffmpeg as ffmpeg_lib
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
PROXY_DIR = Path("/tmp/cht_proxy") from cht.config import DATA_DIR
PROXY_DIR = DATA_DIR / "proxies"
PROXY_HEIGHT = 360 # pixels — low enough for speed, high enough to see content PROXY_HEIGHT = 360 # pixels — low enough for speed, high enough to see content

View File

@@ -72,27 +72,23 @@ def extract_scene_frames(input_path, output_dir, scene_threshold=0.10,
meaningfully vs the previous frame. No periodic fallback so static content meaningfully vs the previous frame. No periodic fallback so static content
produces no spurious frames. produces no spurious frames.
start_time/duration: applied via the select filter expression (NOT as -ss/-t Uses -ss input seeking for O(1) startup regardless of file size.
input options, which break h264 scene detection on MKV). pts_time in showinfo output is relative to the seek point.
Returns (stdout, stderr) as decoded strings for timestamp parsing. Returns (stdout, stderr) as decoded strings for timestamp parsing.
""" """
scene_expr = f"gt(scene,{scene_threshold})" scene_expr = f"gt(scene,{scene_threshold})"
# Add time range filter if specified (incremental processing) # With -ss input seeking, t starts at 0 from the seek point.
time_conditions = [] # Only need end boundary (duration), start is handled by -ss.
if start_time > 0:
time_conditions.append(f"gte(t,{start_time})")
if duration is not None: if duration is not None:
time_conditions.append(f"lte(t,{start_time + duration})") scene_expr = f"({scene_expr})*lte(t,{duration})"
if time_conditions: input_kwargs = {}
time_filter = "*".join(time_conditions) if start_time > 0:
select_expr = f"({scene_expr})*{time_filter}" input_kwargs["ss"] = start_time
else:
select_expr = scene_expr
stream = ffmpeg.input(str(input_path)) stream = ffmpeg.input(str(input_path), **input_kwargs)
stream = stream.filter("select", select_expr).filter("showinfo") stream = stream.filter("select", scene_expr).filter("showinfo")
output = ( output = (
ffmpeg.output( ffmpeg.output(

View File

@@ -68,6 +68,7 @@ class StreamManager:
self._segment_offsets = {0: 0.0} # segment_index → global_offset 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
self.telemetry = None # set by window after start
log.info("Session: %s", session_id) log.info("Session: %s", session_id)
@classmethod @classmethod
@@ -306,6 +307,8 @@ class StreamManager:
def _detect_scenes(self, start_time, end_time): def _detect_scenes(self, start_time, end_time):
"""Run ffmpeg scene detection on a time range. Returns list of new frame entries.""" """Run ffmpeg scene detection on a time range. Returns list of new frame entries."""
import time as _time
t0 = _time.monotonic()
duration = end_time - start_time duration = end_time - start_time
start_number = self._next_frame_number() start_number = self._next_frame_number()
@@ -335,7 +338,8 @@ class StreamManager:
continue continue
pts_match = re.search(r"pts_time:\s*([\d.]+)", line) pts_match = re.search(r"pts_time:\s*([\d.]+)", line)
if pts_match: if pts_match:
pts_time = float(pts_match.group(1)) # pts_time is relative to -ss seek point, add start_time for local offset
pts_time = float(pts_match.group(1)) + start_time
frame_id = f"F{frame_num:04d}" frame_id = f"F{frame_num:04d}"
frame_path = self.frames_dir / f"{frame_id}.jpg" frame_path = self.frames_dir / f"{frame_id}.jpg"
if frame_path.exists(): if frame_path.exists():
@@ -350,6 +354,20 @@ class StreamManager:
frame_num += 1 frame_num += 1
index_path.write_text(json.dumps(index, indent=2)) index_path.write_text(json.dumps(index, indent=2))
elapsed_ms = (_time.monotonic() - t0) * 1000
tel = getattr(self, "telemetry", None)
if tel:
tel.metric("scene_detection", {
"start": start_time, "end": end_time,
"duration": duration,
"frames_found": len(new_frames),
"total_frames": len(index),
"threshold": self.scene_threshold,
"elapsed_ms": round(elapsed_ms),
"file_duration": self._estimate_safe_duration() or 0,
})
return new_frames return new_frames
def capture_now(self, on_new_frames=None): def capture_now(self, on_new_frames=None):

88
cht/telemetry.py Normal file
View File

@@ -0,0 +1,88 @@
"""Session telemetry — lightweight event/metric log for post-run analysis.
Writes a JSON-lines file (one event per line) to the session directory.
Each event has a timestamp, type, and payload. Designed to be grep-friendly.
Usage:
tel = Telemetry(session_dir)
tel.event("scene_threshold_changed", {"from": 0.10, "to": 0.15})
tel.metric("scene_detection", {"start": 120.0, "end": 135.0, "frames_found": 3, "elapsed_ms": 1200})
tel.close()
"""
import json
import logging
import time
from pathlib import Path
log = logging.getLogger(__name__)
class Telemetry:
def __init__(self, session_dir: Path):
self._path = session_dir / "telemetry.jsonl"
self._start = time.monotonic()
self._wall_start = time.time()
self._file = None
self._log_handler = None
try:
session_dir.mkdir(parents=True, exist_ok=True)
self._file = open(self._path, "a")
except Exception as e:
log.warning("Telemetry init failed: %s", e)
# Also save full logs to session directory
try:
log_path = session_dir / "session.log"
handler = logging.FileHandler(str(log_path), mode="a")
handler.setLevel(logging.DEBUG)
handler.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-7s %(name)s: %(message)s",
datefmt="%H:%M:%S",
))
logging.getLogger().addHandler(handler)
self._log_handler = handler
except Exception as e:
log.warning("Log file handler failed: %s", e)
self.event("session_start", {"session_dir": str(session_dir)})
def event(self, name: str, data: dict | None = None) -> None:
"""Log a discrete event (setting change, mode switch, user action)."""
self._write("event", name, data or {})
def metric(self, name: str, data: dict) -> None:
"""Log a measurement (processing time, frame count, etc)."""
self._write("metric", name, data)
def _write(self, kind: str, name: str, data: dict) -> None:
if not self._file:
return
entry = {
"t": round(time.monotonic() - self._start, 3),
"wall": round(time.time(), 3),
"kind": kind,
"name": name,
**data,
}
try:
self._file.write(json.dumps(entry) + "\n")
self._file.flush()
except Exception:
pass
def close(self) -> None:
self.event("session_end", {})
if self._log_handler:
logging.getLogger().removeHandler(self._log_handler)
try:
self._log_handler.close()
except Exception:
pass
self._log_handler = None
if self._file:
try:
self._file.close()
except Exception:
pass
self._file = None

View File

@@ -28,6 +28,7 @@ from cht.ui.session_dialog import SessionDialog
from cht.session import load_frame_index, load_segment_manifest, rebuild_manifest, global_time_to_segment from cht.session import load_frame_index, load_segment_manifest, rebuild_manifest, global_time_to_segment
from cht.scrub.manager import ProxyManager from cht.scrub.manager import ProxyManager
from cht.agent.runner import AgentRunner, check_claude_cli from cht.agent.runner import AgentRunner, check_claude_cli
from cht.telemetry import Telemetry
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -42,6 +43,7 @@ class ChtWindow(Adw.ApplicationWindow):
self._manifest = [] self._manifest = []
self._pending_scrub_global = 0.0 self._pending_scrub_global = 0.0
self._scrub_pending = False # throttle flag for scrub updates self._scrub_pending = False # throttle flag for scrub updates
self._telemetry = None
# Core components # Core components
self._timeline = Timeline() self._timeline = Timeline()
@@ -158,11 +160,17 @@ class ChtWindow(Adw.ApplicationWindow):
def _on_scene_threshold(self, val): def _on_scene_threshold(self, val):
if self._lifecycle.stream_mgr: if self._lifecycle.stream_mgr:
old = self._lifecycle.stream_mgr.scene_threshold
self._lifecycle.stream_mgr.scene_threshold = val self._lifecycle.stream_mgr.scene_threshold = val
if self._telemetry:
self._telemetry.event("scene_threshold_changed", {"from": old, "to": val})
def _on_min_chunk_changed(self, panel, val): def _on_min_chunk_changed(self, panel, val):
import cht.config import cht.config
old = cht.config.TRANSCRIBE_MIN_CHUNK_S
cht.config.TRANSCRIBE_MIN_CHUNK_S = val cht.config.TRANSCRIBE_MIN_CHUNK_S = val
if self._telemetry:
self._telemetry.event("min_chunk_changed", {"from": old, "to": val})
def _on_lines_per_group_changed(self, panel, val): def _on_lines_per_group_changed(self, panel, val):
import cht.config import cht.config
@@ -268,6 +276,8 @@ class ChtWindow(Adw.ApplicationWindow):
self._connect_btn.add_css_class("destructive-action") self._connect_btn.add_css_class("destructive-action")
mgr = self._lifecycle.start(session_id=session_id) mgr = self._lifecycle.start(session_id=session_id)
self._telemetry = Telemetry(mgr.session_dir)
mgr.telemetry = self._telemetry
self._monitor.set_recording(mgr.recording_path) self._monitor.set_recording(mgr.recording_path)
self._monitor.set_live_source(mgr.relay_url) self._monitor.set_live_source(mgr.relay_url)
@@ -285,12 +295,13 @@ class ChtWindow(Adw.ApplicationWindow):
def _on_live_toggle(self): def _on_live_toggle(self):
if self._timeline.state.live: if self._timeline.state.live:
# Live → Scrub: don't load growing MKV, let user pick a segment if self._telemetry:
self._telemetry.event("mode_switch", {"from": "live", "to": "scrub"})
self._timeline.toggle_live(live_player_pos=self._monitor.get_live_position()) 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() self._update_scrub_bar_manifest()
else: else:
# Scrub → Live: restore recording path, refresh GUI, resume if self._telemetry:
self._telemetry.event("mode_switch", {"from": "scrub", "to": "live"})
mgr = self._lifecycle.stream_mgr mgr = self._lifecycle.stream_mgr
if mgr: if mgr:
self._monitor.set_recording(mgr.recording_path) self._monitor.set_recording(mgr.recording_path)
@@ -442,6 +453,10 @@ class ChtWindow(Adw.ApplicationWindow):
mgr = self._lifecycle.stream_mgr mgr = self._lifecycle.stream_mgr
last_session_id = mgr.session_id if mgr and not mgr.readonly else None last_session_id = mgr.session_id if mgr and not mgr.readonly else None
if self._telemetry:
self._telemetry.close()
self._telemetry = None
self._lifecycle.stop() self._lifecycle.stop()
if self._proxy_mgr: if self._proxy_mgr: