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. - **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/`. - **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. - **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. - **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 ## Supported source formats
@ -77,7 +78,8 @@ Removes the installed app tree, launcher, desktop entry, all six icon sizes, the
"naming": "keep" | "suffix", "naming": "keep" | "suffix",
"out_dir": "/absolute/path/to/output/folder", "out_dir": "/absolute/path/to/output/folder",
"audio_bits": 16 | 24, "audio_bits": 16 | 24,
"auto_reveal": true | false "auto_reveal": true | false,
"cpu_pane_expanded": true | false
} }
``` ```

View file

@ -12,7 +12,8 @@ Schema (all keys optional):
"profile": "hq", "profile": "hq",
"naming": "suffix" | "keep", "naming": "suffix" | "keep",
"audio_bits": 16 | 24, "audio_bits": 16 | 24,
"auto_reveal": false "auto_reveal": false,
"cpu_pane_expanded": true
} }
""" """
from __future__ import annotations 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._overall = 0.0
self._current_idx = 0 self._current_idx = 0
self._speed: Optional[float] = None self._speed: Optional[float] = None
self._elapsed: Optional[float] = None
self._build() self._build()
@ -50,13 +51,15 @@ class Footer(Gtk.Box):
def update(self, state: str, files: list[FileEntry], profile_id: str, def update(self, state: str, files: list[FileEntry], profile_id: str,
overall: float, current_idx: int, overall: float, current_idx: int,
speed: Optional[float] = None) -> None: speed: Optional[float] = None,
elapsed: Optional[float] = None) -> None:
self._state = state self._state = state
self._files = files self._files = files
self._profile_id = profile_id self._profile_id = profile_id
self._overall = max(0.0, min(1.0, overall)) self._overall = max(0.0, min(1.0, overall))
self._current_idx = current_idx self._current_idx = current_idx
self._speed = speed self._speed = speed
self._elapsed = elapsed
self._render() self._render()
# ---------- build ---------- # ---------- build ----------
@ -161,6 +164,9 @@ class Footer(Gtk.Box):
stats.append(_divider()) stats.append(_divider())
self._stat_out = _make_stat("Output size", "", small=True) self._stat_out = _make_stat("Output size", "", small=True)
stats.append(self._stat_out.root) 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) box.append(stats)
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
@ -266,6 +272,10 @@ class Footer(Gtk.Box):
else: else:
self._stat_fail.value.remove_css_class("danger") self._stat_fail.value.remove_css_class("danger")
self._stat_out.value.set_text(format_bytes(total_out) if total_out else "") 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 ---------- # ---------- 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 os
import shutil import shutil
import threading import threading
import time
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -39,8 +40,9 @@ from .encoder import (
) )
from .footer import Footer from .footer import Footer
from .queue_pane import FileEntry, QueuePane 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 .settings_pane import Settings, SettingsPane, load_persisted_settings
from .system_pane import SystemPane
WINDOW_WIDTH = 1280 WINDOW_WIDTH = 1280
WINDOW_HEIGHT = 880 WINDOW_HEIGHT = 880
@ -73,6 +75,8 @@ class MainWindow(Adw.ApplicationWindow):
self._active_job: Optional[EncodeJob] = None self._active_job: Optional[EncodeJob] = None
self._current_idx: int = 0 self._current_idx: int = 0
self._current_speed: Optional[float] = None self._current_speed: Optional[float] = None
self._encode_start_mono: Optional[float] = None
self._encode_elapsed: Optional[float] = None
# Root layout # Root layout
root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 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()) self._settings_pane.connect("choose-folder-requested", lambda *_: self._open_out_dir_dialog())
split.append(self._settings_pane) 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 = Footer()
self._footer.connect("encode-requested", lambda *_: self._start_encode()) self._footer.connect("encode-requested", lambda *_: self._start_encode())
self._footer.connect("cancel-requested", lambda *_: self._cancel_encode()) self._footer.connect("cancel-requested", lambda *_: self._cancel_encode())
@ -253,6 +261,7 @@ class MainWindow(Adw.ApplicationWindow):
profile_id=self._settings.profile, profile_id=self._settings.profile,
overall=self._overall_progress(), overall=self._overall_progress(),
current_idx=self._current_idx, current_idx=self._current_idx,
elapsed=self._encode_elapsed,
) )
def _overall_progress(self) -> float: def _overall_progress(self) -> float:
@ -512,6 +521,8 @@ class MainWindow(Adw.ApplicationWindow):
self._state = "encoding" self._state = "encoding"
self._current_idx = 0 self._current_idx = 0
self._cancel_event = threading.Event() self._cancel_event = threading.Event()
self._encode_start_mono = time.monotonic()
self._encode_elapsed = None
self._refresh_all() self._refresh_all()
self._encode_thread = threading.Thread(target=self._encode_worker, daemon=True) self._encode_thread = threading.Thread(target=self._encode_worker, daemon=True)
@ -651,11 +662,14 @@ class MainWindow(Adw.ApplicationWindow):
self._state = "ready" self._state = "ready"
else: else:
self._state = "complete" 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 # If at least one file landed AND the user opted in, pop the
# output folder open. Skip on cancel (intent unclear) and on # output folder open. Skip on cancel (intent unclear) and on
# full-batch failure (annoying to be shown an empty folder). # 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): if self._settings.auto_reveal and any(f.status == "done" for f in self._files):
self._reveal_output_dir() self._reveal_output_dir()
self._encode_start_mono = None
self._refresh_all() self._refresh_all()
return False return False

View file

@ -591,6 +591,40 @@ button.cmd-disclosure:hover { color: @text_main; background-color: alpha(@text_m
caret-color: @accent; 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 ---------------- */
.footer-bar { .footer-bar {