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:
parent
7d66dcac87
commit
e72a46cce5
7 changed files with 278 additions and 10 deletions
|
|
@ -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
|
||||
|
|
@ -77,7 +78,8 @@ Removes the installed app tree, launcher, desktop entry, all six icon sizes, the
|
|||
"naming": "keep" | "suffix",
|
||||
"out_dir": "/absolute/path/to/output/folder",
|
||||
"audio_bits": 16 | 24,
|
||||
"auto_reveal": true | false
|
||||
"auto_reveal": true | false,
|
||||
"cpu_pane_expanded": true | false
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
70
nocoder/cpu_sampler.py
Normal 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 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
|
||||
|
|
@ -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
137
nocoder/system_pane.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
34
style.css
34
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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue