Add per-core CPU visualizer and total-encode-time stat

* New collapsible pane sits between the main split and the footer and
  shows per-core CPU utilisation as btop-style vertical bars. Polls
  /proc/stat once a second, paints with the live Omarchy accent colour
  via Cairo. Collapsed/expanded state persists in
  ~/.config/nocoder/config.json as cpu_pane_expanded (default true) so
  the pane reopens however the user last left it.

* Footer "complete" state now reports TOTAL TIME alongside Succeeded /
  Failed / Output size. Wall time captured via time.monotonic() when the
  encode worker starts and again when the state flips to complete; only
  successful (non-cancelled) batches get a time. Formatted via the
  existing data.format_duration helper so it matches the pre-encode
  estimate's display.

The /proc/stat parser lives in its own pure-Python module
(nocoder/cpu_sampler.py) so it's GTK-free and easy to test in isolation.
SystemPane mirrors the Footer attachment pattern (Gtk.Box subclass +
__gtype_name__ + add_css_class), attached from MainWindow exactly like
the footer is. Timer auto-stops on widget detach via a get_root() check,
so no explicit teardown wiring is needed.

CSS gets a .system-pane block (top border + padding) and a .cpu-bar-area
rule that sets foreground colour to @accent — the Cairo draw callback
reads that via widget.get_color() so the bars track the live theme
without re-plumbing.

Verified:
* CpuSampler reports 32 cores at idle baseline; under stress-ng --cpu 0
  aggregate hit 99.9% with all bars saturated.
* Theme accent cascades correctly; bars use the active Omarchy theme.
* cpu_pane_expanded round-trips (manually setting false then relaunching
  reopens the pane collapsed).
* TOTAL TIME shows 0:02 for a 2.28s encode of a 3-second HEVC test clip.
* Installed copy at ~/.local/share/nocoder/ updated and verified via the
  walker launcher.
This commit is contained in:
28allday 2026-05-19 07:06:04 +01:00
parent 7d66dcac87
commit e72a46cce5
7 changed files with 278 additions and 10 deletions

View file

@ -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
}
```

View file

@ -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

70
nocoder/cpu_sampler.py Normal file
View file

@ -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 0100.
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

View file

@ -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 ----------

137
nocoder/system_pane.py Normal file
View file

@ -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()

View file

@ -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

View file

@ -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 {