diff --git a/README.md b/README.md index 8335fcc..769b73f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ A native GTK4 + libadwaita batch transcoder for Omarchy. Drop video files (or wh - **Theme-aware** — palette tracks the active Omarchy theme on every launch (parses `colors.toml` / `ghostty.conf` / `alacritty.toml` / `kitty.conf` in priority order). 34 stock + custom themes verified. - **Pro camera ready** — `.MXF` from Canon XF / Sony XDCAM / Panasonic AVC-Intra, with proxy-directory pruning so dropping a Sony XAVC card maps only the masters in `CLIP/` and not the low-res duplicates in `SUB/`. - **Multi-track audio preserved** — Canon C300/C500 records 4 mono PCM streams; all four land in the output `.mov` as separate tracks. Optional 24-bit toggle for pro delivery. -- **Live encode-speed indicator** — footer shows real `1.5×` throughput from ffmpeg and refines the ETA from actual measured rate, not a fixed heuristic. +- **Live encode-speed indicator** — footer shows real `1.5×` throughput from ffmpeg and refines the ETA from actual measured rate, not a fixed heuristic. After the batch finishes, the footer also reports total wall time alongside succeeded / failed / output size. +- **Per-core CPU visualizer** — collapsible btop-style pane between the queue and the footer, polling `/proc/stat` once a second and painting one vertical bar per logical core in the live theme accent. Useful for seeing at a glance whether the chosen profile / hwaccel combo is saturating the box or leaving headroom. Expand/collapse state persists across launches. - **Hyprland-aware install** — registers a `.desktop` entry with the walker, installs the icon at six hicolor sizes, appends a windowrule that floats and centres the app at 1280×880. ## Supported source formats @@ -72,12 +73,13 @@ Removes the installed app tree, launcher, desktop entry, all six icon sizes, the ```json { - "hwaccel": "cuda" | "qsv" | "vaapi" | "none", - "profile": "proxy" | "lt" | "standard" | "hq" | "4444" | "4444xq", - "naming": "keep" | "suffix", - "out_dir": "/absolute/path/to/output/folder", - "audio_bits": 16 | 24, - "auto_reveal": true | false + "hwaccel": "cuda" | "qsv" | "vaapi" | "none", + "profile": "proxy" | "lt" | "standard" | "hq" | "4444" | "4444xq", + "naming": "keep" | "suffix", + "out_dir": "/absolute/path/to/output/folder", + "audio_bits": 16 | 24, + "auto_reveal": true | false, + "cpu_pane_expanded": true | false } ``` diff --git a/nocoder/config.py b/nocoder/config.py index e66289f..68f090d 100644 --- a/nocoder/config.py +++ b/nocoder/config.py @@ -12,7 +12,8 @@ Schema (all keys optional): "profile": "hq", "naming": "suffix" | "keep", "audio_bits": 16 | 24, - "auto_reveal": false + "auto_reveal": false, + "cpu_pane_expanded": true } """ from __future__ import annotations diff --git a/nocoder/cpu_sampler.py b/nocoder/cpu_sampler.py new file mode 100644 index 0000000..b158962 --- /dev/null +++ b/nocoder/cpu_sampler.py @@ -0,0 +1,70 @@ +"""Per-core CPU utilisation sampling from /proc/stat. + +Pure-Python, no GTK. Keeps a snapshot of the last (idle, total) jiffies per +core so each ``sample()`` returns the delta-derived utilisation since the +previous call. The first call returns zeros (no baseline yet). +""" +from __future__ import annotations + +import os +from typing import List, Optional, Tuple + +PROC_STAT = "/proc/stat" + + +class CpuSampler: + def __init__(self) -> None: + self._prev: List[Optional[Tuple[int, int]]] = [] + + def sample(self) -> List[float]: + """Return per-core utilisation 0–100. + + First call returns a list of zeros sized to ``os.cpu_count()`` (or the + number of per-core lines in /proc/stat, whichever is found). + """ + snapshot = _read_cpu_jiffies() + if not self._prev: + self._prev = [None] * len(snapshot) + + # /proc/stat may grow if a core comes online between calls. Pad prev. + if len(snapshot) > len(self._prev): + self._prev.extend([None] * (len(snapshot) - len(self._prev))) + + out: List[float] = [] + for i, cur in enumerate(snapshot): + prev = self._prev[i] if i < len(self._prev) else None + if prev is None: + out.append(0.0) + else: + d_idle = cur[0] - prev[0] + d_total = cur[1] - prev[1] + if d_total <= 0: + out.append(0.0) + else: + busy = d_total - d_idle + out.append(max(0.0, min(100.0, 100.0 * busy / d_total))) + self._prev[i] = cur + return out + + +def _read_cpu_jiffies() -> List[Tuple[int, int]]: + """Parse /proc/stat and return [(idle, total), ...] per core (cpu0..cpuN).""" + rows: List[Tuple[int, int]] = [] + try: + with open(PROC_STAT, "r") as f: + for line in f: + if not line.startswith("cpu"): + break + parts = line.split() + name = parts[0] + if name == "cpu": + continue # aggregate row; skip + # Fields: user nice system idle iowait irq softirq steal guest guest_nice + # idle time = idle + iowait + fields = [int(x) for x in parts[1:]] + idle = fields[3] + (fields[4] if len(fields) > 4 else 0) + total = sum(fields) + rows.append((idle, total)) + except OSError: + return [] + return rows diff --git a/nocoder/footer.py b/nocoder/footer.py index 1acd5fc..be82fa2 100644 --- a/nocoder/footer.py +++ b/nocoder/footer.py @@ -43,6 +43,7 @@ class Footer(Gtk.Box): self._overall = 0.0 self._current_idx = 0 self._speed: Optional[float] = None + self._elapsed: Optional[float] = None self._build() @@ -50,13 +51,15 @@ class Footer(Gtk.Box): def update(self, state: str, files: list[FileEntry], profile_id: str, overall: float, current_idx: int, - speed: Optional[float] = None) -> None: + speed: Optional[float] = None, + elapsed: Optional[float] = None) -> None: self._state = state self._files = files self._profile_id = profile_id self._overall = max(0.0, min(1.0, overall)) self._current_idx = current_idx self._speed = speed + self._elapsed = elapsed self._render() # ---------- build ---------- @@ -161,6 +164,9 @@ class Footer(Gtk.Box): stats.append(_divider()) self._stat_out = _make_stat("Output size", "—", small=True) stats.append(self._stat_out.root) + stats.append(_divider()) + self._stat_total_time = _make_stat("Total time", "—", small=True, with_clock=True) + stats.append(self._stat_total_time.root) box.append(stats) actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) @@ -266,6 +272,10 @@ class Footer(Gtk.Box): else: self._stat_fail.value.remove_css_class("danger") self._stat_out.value.set_text(format_bytes(total_out) if total_out else "—") + if self._elapsed is not None and self._elapsed > 0: + self._stat_total_time.value.set_text(format_duration(self._elapsed)) + else: + self._stat_total_time.value.set_text("—") # ---------- helpers ---------- diff --git a/nocoder/system_pane.py b/nocoder/system_pane.py new file mode 100644 index 0000000..7eeb0ba --- /dev/null +++ b/nocoder/system_pane.py @@ -0,0 +1,137 @@ +"""SystemPane — collapsible per-core CPU visualizer (btop-style). + +A thin row sandwiched between the main split and the footer. Header shows a +disclosure toggle plus a "CPU" label and an aggregate percentage; the body is +a ``Gtk.DrawingArea`` rendering one vertical bar per logical core using Cairo. + +Polls /proc/stat once a second via ``GLib.timeout_add_seconds``. The timer +returns ``False`` (auto-stops) once the widget is detached from any root, so +no explicit teardown signal wiring is needed. +""" +from __future__ import annotations + +import gi +gi.require_version("Gtk", "4.0") +gi.require_version("GObject", "2.0") +from gi.repository import GLib, GObject, Gtk + +from .config import update_config +from .cpu_sampler import CpuSampler + + +_SAMPLE_INTERVAL_SECONDS = 1 +_BAR_MIN_WIDTH = 4 +_BAR_MAX_WIDTH = 14 +_BAR_GAP = 2 +_AREA_MIN_HEIGHT = 56 + + +class SystemPane(Gtk.Box): + __gtype_name__ = "NoCoderSystemPane" + + def __init__(self, *, initial_expanded: bool = True) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("system-pane") + self.set_hexpand(True) + + self._sampler = CpuSampler() + self._last: list[float] = [] + + # ---- Header row: toggle ▾/▸ + "CPU" label + aggregate % ---- + header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + header.add_css_class("system-pane-header") + + self._toggle = Gtk.ToggleButton() + self._toggle.add_css_class("flat") + self._toggle.add_css_class("system-pane-toggle") + self._toggle.set_active(initial_expanded) + self._toggle_label = Gtk.Label() + self._toggle.set_child(self._toggle_label) + self._toggle.connect("toggled", self._on_toggled) + header.append(self._toggle) + + title = Gtk.Label(label="CPU", xalign=0) + title.add_css_class("system-pane-title") + header.append(title) + + self._agg_label = Gtk.Label(label="—", xalign=1.0) + self._agg_label.add_css_class("system-pane-agg") + self._agg_label.set_hexpand(True) + header.append(self._agg_label) + + self.append(header) + + # ---- Body: revealer wrapping the cairo bar canvas ---- + self._revealer = Gtk.Revealer() + self._revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN) + self._revealer.set_transition_duration(150) + self._revealer.set_reveal_child(initial_expanded) + + self._area = Gtk.DrawingArea() + self._area.add_css_class("cpu-bar-area") + self._area.set_content_height(_AREA_MIN_HEIGHT) + self._area.set_hexpand(True) + self._area.set_draw_func(self._draw, None) + self._revealer.set_child(self._area) + self.append(self._revealer) + + self._refresh_toggle_label() + + # Kick off the polling timer. Returns False to auto-cleanup when the + # pane is no longer rooted (e.g. window closed). + GLib.timeout_add_seconds(_SAMPLE_INTERVAL_SECONDS, self._tick) + # Render an initial frame immediately so bars aren't blank for a second. + self._tick() + + # ---------- timer ---------- + + def _tick(self) -> bool: + if self.get_root() is None: + return False + self._last = self._sampler.sample() + if self._last: + avg = sum(self._last) / len(self._last) + self._agg_label.set_text(f"{avg:5.1f}% · {len(self._last)} threads") + self._area.queue_draw() + return True + + # ---------- toggle ---------- + + def _on_toggled(self, _btn: Gtk.ToggleButton) -> None: + expanded = self._toggle.get_active() + self._revealer.set_reveal_child(expanded) + self._refresh_toggle_label() + update_config({"cpu_pane_expanded": expanded}) + + def _refresh_toggle_label(self) -> None: + self._toggle_label.set_text("▾" if self._toggle.get_active() else "▸") + + # ---------- draw ---------- + + def _draw(self, area: Gtk.DrawingArea, cr, w: int, h: int, _user) -> None: + n = len(self._last) + if n <= 0 or w <= 0 or h <= 0: + return + + # The widget's foreground colour (set via CSS to the theme accent) + # paints the active portion of each bar. + accent = area.get_color() + + # Bar width adapts to fit N cores across the canvas. Cap so a 4-core + # box doesn't get giant bars and a 64-core box doesn't get hairlines. + bar_w = (w - _BAR_GAP * (n - 1)) / n + bar_w = max(_BAR_MIN_WIDTH, min(_BAR_MAX_WIDTH, bar_w)) + total_w = bar_w * n + _BAR_GAP * (n - 1) + x0 = (w - total_w) / 2 # centre the row + + for i, pct in enumerate(self._last): + x = x0 + i * (bar_w + _BAR_GAP) + # Muted track + cr.set_source_rgba(accent.red, accent.green, accent.blue, 0.14) + cr.rectangle(x, 0, bar_w, h) + cr.fill() + # Filled portion (bottom-up) + fill_h = max(1.0, h * (pct / 100.0)) + cr.set_source_rgba(accent.red, accent.green, accent.blue, accent.alpha) + cr.rectangle(x, h - fill_h, bar_w, fill_h) + cr.fill() diff --git a/nocoder/window.py b/nocoder/window.py index 1bfda9f..97d3203 100644 --- a/nocoder/window.py +++ b/nocoder/window.py @@ -8,6 +8,7 @@ from __future__ import annotations import os import shutil import threading +import time from pathlib import Path from typing import Optional @@ -39,8 +40,9 @@ from .encoder import ( ) from .footer import Footer from .queue_pane import FileEntry, QueuePane -from .config import update_config +from .config import load_config, update_config from .settings_pane import Settings, SettingsPane, load_persisted_settings +from .system_pane import SystemPane WINDOW_WIDTH = 1280 WINDOW_HEIGHT = 880 @@ -73,6 +75,8 @@ class MainWindow(Adw.ApplicationWindow): self._active_job: Optional[EncodeJob] = None self._current_idx: int = 0 self._current_speed: Optional[float] = None + self._encode_start_mono: Optional[float] = None + self._encode_elapsed: Optional[float] = None # Root layout root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) @@ -99,6 +103,10 @@ class MainWindow(Adw.ApplicationWindow): self._settings_pane.connect("choose-folder-requested", lambda *_: self._open_out_dir_dialog()) split.append(self._settings_pane) + cpu_pane_expanded = bool(load_config().get("cpu_pane_expanded", True)) + self._system_pane = SystemPane(initial_expanded=cpu_pane_expanded) + root.append(self._system_pane) + self._footer = Footer() self._footer.connect("encode-requested", lambda *_: self._start_encode()) self._footer.connect("cancel-requested", lambda *_: self._cancel_encode()) @@ -253,6 +261,7 @@ class MainWindow(Adw.ApplicationWindow): profile_id=self._settings.profile, overall=self._overall_progress(), current_idx=self._current_idx, + elapsed=self._encode_elapsed, ) def _overall_progress(self) -> float: @@ -512,6 +521,8 @@ class MainWindow(Adw.ApplicationWindow): self._state = "encoding" self._current_idx = 0 self._cancel_event = threading.Event() + self._encode_start_mono = time.monotonic() + self._encode_elapsed = None self._refresh_all() self._encode_thread = threading.Thread(target=self._encode_worker, daemon=True) @@ -651,11 +662,14 @@ class MainWindow(Adw.ApplicationWindow): self._state = "ready" else: self._state = "complete" + if self._encode_start_mono is not None: + self._encode_elapsed = time.monotonic() - self._encode_start_mono # If at least one file landed AND the user opted in, pop the # output folder open. Skip on cancel (intent unclear) and on # full-batch failure (annoying to be shown an empty folder). if self._settings.auto_reveal and any(f.status == "done" for f in self._files): self._reveal_output_dir() + self._encode_start_mono = None self._refresh_all() return False diff --git a/style.css b/style.css index 8321a7c..c6253a4 100644 --- a/style.css +++ b/style.css @@ -591,6 +591,40 @@ button.cmd-disclosure:hover { color: @text_main; background-color: alpha(@text_m caret-color: @accent; } +/* ---------------- System pane (CPU visualizer) ---------------- */ + +.system-pane { + border-top: 1px solid @border_muted; + background-color: @window_bg; + padding: 4px 18px 6px 18px; +} +.system-pane-header { + min-height: 22px; +} +.system-pane-toggle { + min-width: 22px; + min-height: 22px; + padding: 0 4px; + color: @text_dim; +} +.system-pane-toggle:hover { color: @text_main; } +.system-pane-title { + font-size: 10px; + color: @text_dim; + letter-spacing: 1px; + font-weight: 600; +} +.system-pane-agg { + font-size: 11px; + color: @text_muted; + font-family: "JetBrains Mono", monospace; + font-feature-settings: "tnum"; +} +.cpu-bar-area { + color: @accent; + margin-top: 4px; +} + /* ---------------- Footer ---------------- */ .footer-bar {