Initial release: NO-CODER batch ProRes transcoder for Omarchy

Native GTK4 + libadwaita app that wraps ffmpeg to batch-convert source
video into editorial-ready Apple ProRes .mov. Targets Omarchy / Hyprland
on Arch Linux specifically.

Highlights:

* Real ffmpeg encode (prores_ks → prores fallback) with live progress
  parsing, cancelable serial queue, disk-space pre-check, source-missing
  guard, output-collision (N) suffixes.
* GPU decode auto-probe at install time — picks cuda → qsv → vaapi based
  on what actually initialises on the host. ProRes encoding stays on CPU
  (no vendor ships a GPU encoder); offloading the decode side cuts wall
  time 25-40% on H.264 / HEVC sources.
* Theme-aware: tracks the active Omarchy theme on every launch by
  parsing colors.toml / ghostty.conf / alacritty.toml / kitty.conf in
  priority order. 34 stock + custom themes verified.
* Pro camera support: .MXF (Canon XF / Sony XDCAM / Panasonic AVC-Intra)
  with proxy-directory pruning so dropping a Sony XAVC card maps masters
  in CLIP/ but skips the low-res duplicates in SUB/.
* Multi-track audio preserved — 4 mono PCM streams from a Canon C300/C500
  land in the output as 4 separate tracks. Optional 24-bit toggle.
* Live encode-speed indicator with ffmpeg -progress parsing; ETA refines
  from real measured throughput rather than a fixed heuristic.
* Hyprland-aware install — registers walker entry, six hicolor icon
  sizes, float+centre windowrule for class dev.nocoder.NoCoder.

Distribution model: git clone + bash install.sh. The installer copies the
source tree to ~/.local/share/nocoder/ so the clone is disposable. Updates
are git pull + re-run install.sh.

Documented at README.md.
This commit is contained in:
28allday 2026-04-21 20:43:14 +01:00
commit 749e102bd5
18 changed files with 4802 additions and 0 deletions

19
.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
# Python bytecode
__pycache__/
*.pyc
*.pyo
# Virtualenvs (in case someone clones and sets one up locally)
.venv/
venv/
# Editor / OS cruft
.vscode/
.idea/
.DS_Store
Thumbs.db
*.swp
*.swo
# Runtime logs written during local testing
*.log

90
README.md Normal file
View file

@ -0,0 +1,90 @@
# NO-CODER
A native GTK4 + libadwaita batch transcoder for Omarchy. Drop video files (or whole camera cards) onto the window, choose a ProRes profile, hit Encode. Output is editorial-ready Apple ProRes `.mov` ready for DaVinci Resolve, Premiere, FCP, Avid.
The brand is the **NO SIGNAL** circle — every other colour follows whichever Omarchy theme you have active.
![drop zone with the NO SIGNAL logo](assets/logo.png)
## Features
- **Real ffmpeg encode**`prores_ks` (with fallback to plain `prores`), live progress bar parsed from `-progress pipe:1`, cancelable, serial queue with disk-space pre-check.
- **GPU decode auto-probe** — installer tests `cuda``qsv``vaapi` and pins the working one to `~/.config/nocoder/config.json`. ProRes encoding stays on CPU (no vendor ships a GPU ProRes encoder), but offloading the *decode* side cuts wall time by 25-40% on H.264 / HEVC / AV1 sources.
- **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.
- **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
`.mov` `.mp4` `.m4v` `.mkv` `.avi` `.mts` `.m2ts` `.webm` `.mpeg` `.mpg` `.3gp` `.3g2` `.mxf`
**Not supported** (proprietary RAW; ffmpeg has no decoder without vendor SDKs): `.crm` (Canon Cinema RAW Light), `.braw` (Blackmagic), `.r3d` (RED), `.ari` (Arri). Pre-transcode those via Canon Cinema RAW Development / Blackmagic RAW Player / REDCINE-X / ARRI Meta Extract first, then bring the resulting MXF or MOV into NO-CODER.
## Install
Targets Arch / Omarchy specifically.
```sh
git clone https://git.no-signal.uk/nosignal/nocoder.git
cd nocoder
bash install.sh
```
The installer:
1. Verifies pacman is present, fails fast otherwise.
2. Installs missing pacman packages: `python python-gobject gtk4 libadwaita ffmpeg`.
3. Installs Inter and JetBrains Mono fonts to `~/.local/share/fonts/` (per-user, no sudo).
4. Probes GPU decode and pins the working backend to `~/.config/nocoder/config.json`.
5. Copies the source tree to `~/.local/share/nocoder/` so you can delete this clone afterward.
6. Writes a launcher to `~/.local/bin/nocoder`.
7. Drops the `.desktop` file and PNG icons into the right XDG locations.
8. Appends Hyprland windowrules (float, centre, 1280×880) inside a marked block in `~/.config/hypr/windows.conf`.
9. Restarts walker so the entry appears immediately.
After install, **Super+Space → "no"** launches it. Or `nocoder` from a shell.
## Updating
```sh
cd nocoder
git pull
bash install.sh
```
Re-running the installer wipes and re-copies the live install dir — files removed upstream propagate cleanly.
## Uninstall
```sh
bash uninstall.sh
```
Removes the installed app tree, launcher, desktop entry, all six icon sizes, the Hyprland windowrules block, and the per-user config. Pacman packages and fonts are left in place (other apps may need them).
## Hardware
- **Required:** anything that runs Omarchy / Hyprland.
- **Recommended:** a GPU with ffmpeg-supported decode (NVIDIA NVDEC, Intel QSV, AMD VAAPI). The probe falls back to CPU decode on systems without; everything still works, just slower on camera-native sources.
- **No upper limit on cores**`prores_ks` is well-parallelised.
## Configuration
`~/.config/nocoder/config.json` — currently just `{"hwaccel": "cuda" | "qsv" | "vaapi" | "none"}`. Edit by hand to override the auto-probed choice.
## Known gaps
- No persistence for last-used output folder / profile (resets to defaults each launch).
- "Reveal in Files" opens the output folder but doesn't *select* the specific file.
- Per-row remove button isn't keyboard-accessible (mouse-hover only, by design — keeps tab order clean).
- No live theme-change pickup — theme swaps apply on next launch, not immediately.
## License
Not yet specified. The app wraps `ffmpeg` and depends on GTK4 / libadwaita; check those licenses for the redistributable parts.
## Credits
Born as a rewrite of `prowrap-yad.sh` (a yad-based ProRes batch transcoder), rebranded NO-CODER to lean into the visual identity. The encoding logic from the original bash script is preserved verbatim.

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

328
install.sh Executable file
View file

@ -0,0 +1,328 @@
#!/usr/bin/env bash
# install.sh — integrate NO-CODER into Omarchy.
#
# Installs pacman dependencies, drops a launcher into ~/.local/bin, registers
# a .desktop entry so the walker finds it, installs the app icon into the
# hicolor theme, and appends Hyprland windowrules so the window always floats
# centered on launch.
#
# Safe to re-run — the Hyprland rules live inside a marked block that is
# replaced (not duplicated) on every install.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC_DIR="$SCRIPT_DIR"
PKG_DIR="$SCRIPT_DIR/packaging"
APP_ID="dev.nocoder.NoCoder"
LAUNCHER_NAME="nocoder"
BIN_DIR="$HOME/.local/bin"
DESKTOP_DIR="$HOME/.local/share/applications"
HICOLOR_DIR="$HOME/.local/share/icons/hicolor"
INSTALL_DIR="$HOME/.local/share/nocoder"
HYPR_CONF="$HOME/.config/hypr/windows.conf"
MARK_BEGIN="# >>> nocoder windowrules begin"
MARK_END="# <<< nocoder windowrules end"
GREEN=$'\e[32m'; YELLOW=$'\e[33m'; RED=$'\e[31m'; DIM=$'\e[2m'; RESET=$'\e[0m'
say() { printf '%s==>%s %s\n' "$GREEN" "$RESET" "$*"; }
warn() { printf '%s[!]%s %s\n' "$YELLOW" "$RESET" "$*" >&2; }
die() { printf '%s[x]%s %s\n' "$RED" "$RESET" "$*" >&2; exit 1; }
# ---------- environment checks ----------
[[ -f "$SRC_DIR/run.py" ]] || die "run.py not found next to install.sh (SRC_DIR=$SRC_DIR)"
[[ -f "$PKG_DIR/$APP_ID.desktop" ]] || die "missing $PKG_DIR/$APP_ID.desktop"
[[ -f "$PKG_DIR/$APP_ID.png" ]] || die "missing $PKG_DIR/$APP_ID.png"
# Guard against running install.sh from inside the install target itself — the
# clean-and-copy step would remove its own script mid-execution.
if [[ "$SRC_DIR" == "$INSTALL_DIR" ]]; then
die "Don't run install.sh from $INSTALL_DIR — run it from your git clone."
fi
if ! command -v pacman >/dev/null 2>&1; then
die "pacman not found — this installer targets Arch/Omarchy only."
fi
if [[ ! -d "$HOME/.local/share/omarchy" ]]; then
warn "$HOME/.local/share/omarchy not found — are you sure this is Omarchy?"
fi
if [[ ! -f "$HOME/.config/hypr/hyprland.conf" ]]; then
die "Hyprland config not found at ~/.config/hypr/hyprland.conf."
fi
# ---------- pacman deps (non-font) ----------
PACMAN_PKGS=(
python
python-gobject
gtk4
libadwaita
ffmpeg
)
# Only invoke sudo/pacman when something is actually missing.
MISSING_PKGS=()
for p in "${PACMAN_PKGS[@]}"; do
pacman -Q "$p" &>/dev/null || MISSING_PKGS+=("$p")
done
if ((${#MISSING_PKGS[@]} == 0)); then
say "All required pacman packages already installed."
else
say "Installing missing pacman packages: ${MISSING_PKGS[*]}"
if command -v omarchy-pkg-add >/dev/null 2>&1; then
omarchy-pkg-add "${MISSING_PKGS[@]}"
else
sudo pacman -S --noconfirm --needed "${MISSING_PKGS[@]}"
fi
fi
# ---------- fonts (per-user, no sudo) ----------
install_font_from_github() {
# $1 friendly name, $2 github repo "owner/name", $3 fc-list match pattern,
# $4 subdir under ~/.local/share/fonts/
local name="$1" repo="$2" fc_pattern="$3" subdir="$4"
# Read fc-list into a var rather than piping to grep -q — with `set -o pipefail`
# grep's early exit gives fc-list a SIGPIPE (141), poisoning the pipeline.
local _fc_all
_fc_all=$(fc-list)
if grep -iqE "$fc_pattern" <<<"$_fc_all"; then
say "$name already available — skipping."
return 0
fi
say "Installing $name to $HOME/.local/share/fonts/$subdir (per-user, no sudo)"
local url
url=$(curl -fsSL "https://api.github.com/repos/$repo/releases/latest" \
| grep -oE '"browser_download_url":[[:space:]]*"[^"]*\.zip"' \
| head -1 | sed -E 's/.*"([^"]*)".*/\1/') || true
if [[ -z "$url" ]]; then
warn "Could not resolve latest $name release — skipping font install."
return 0
fi
local tmpdir
tmpdir=$(mktemp -d)
curl -fsSL -o "$tmpdir/pkg.zip" "$url" || { warn "Download failed: $url"; rm -rf "$tmpdir"; return 0; }
unzip -oq "$tmpdir/pkg.zip" -d "$tmpdir/extract" || { warn "Unzip failed for $name."; rm -rf "$tmpdir"; return 0; }
mkdir -p "$HOME/.local/share/fonts/$subdir"
find "$tmpdir/extract" -type f \( -name "*.otf" -o -name "*.ttf" \) \
-exec cp -f {} "$HOME/.local/share/fonts/$subdir/" \;
rm -rf "$tmpdir"
}
install_font_from_github "Inter" "rsms/inter" '^[^:]*inter[^:]*:' inter
install_font_from_github "JetBrains Mono" "JetBrains/JetBrainsMono" 'jetbrains mono' jetbrains-mono
if command -v fc-cache >/dev/null 2>&1; then
fc-cache -f "$HOME/.local/share/fonts/" >/dev/null 2>&1 || true
fi
# ---------- import smoke test ----------
say "Verifying Python imports"
if ! python3 - <<PY
import sys
sys.path.insert(0, "$SRC_DIR")
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk # noqa
from nocoder.app import NoCoderApplication # noqa
PY
then
die "Python import check failed. A required module (python-gobject / gtk4 / libadwaita) may not be installed properly."
fi
# ---------- copy source tree into $INSTALL_DIR ----------
# Copy runtime files into a stable location so the user can delete the git
# clone after install. Re-runs wipe the target first to purge files removed
# upstream (e.g., from a git pull) before copying fresh.
#
# Pre-flight: verify every source item exists BEFORE wiping the target. A
# missing item post-wipe would leave the user with no installed app.
for item in run.py style.css nocoder assets; do
[[ -e "$SRC_DIR/$item" ]] || die "missing $SRC_DIR/$item — can't install from an incomplete clone"
done
say "Installing source tree to $INSTALL_DIR"
rm -rf "$INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
cp -r \
"$SRC_DIR/run.py" \
"$SRC_DIR/style.css" \
"$SRC_DIR/nocoder" \
"$SRC_DIR/assets" \
"$INSTALL_DIR/"
# Strip any __pycache__ copied from the source tree — they'd go stale anyway
# and Python will regenerate them as needed.
find "$INSTALL_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
# ---------- GPU decode probe ----------
# Test which ffmpeg -hwaccel actually initialises on this box (CUDA on NVIDIA,
# QSV on Intel with intel-media-driver, VAAPI on AMD / Intel fallback) and
# pin the result into ~/.config/nocoder/config.json so the app doesn't re-probe
# on every launch. Decode side only — ProRes encode is always CPU.
say "Probing GPU decode"
# A future regression in hwaccel.py would otherwise abort the whole installer
# post-copy — degrade gracefully to CPU decode so the user still ends up with
# a working app they can inspect.
HW_CHOICE="none"
if HW_OUTPUT="$(python3 - <<PY 2>/dev/null
import sys
sys.path.insert(0, "$INSTALL_DIR")
from nocoder.hwaccel import probe_best_hwaccel, save_hwaccel
choice = probe_best_hwaccel()
save_hwaccel(choice)
print(choice or "none")
PY
)"; then
HW_CHOICE="${HW_OUTPUT:-none}"
else
warn "hwaccel probe failed — defaulting to CPU decode. Run the app once to re-probe."
fi
if [[ "$HW_CHOICE" == "none" ]]; then
say " No GPU decode available — decodes will run on CPU."
else
say " Selected: $HW_CHOICE"
fi
# ---------- launcher script in ~/.local/bin ----------
mkdir -p "$BIN_DIR"
LAUNCHER="$BIN_DIR/$LAUNCHER_NAME"
say "Writing launcher to $LAUNCHER"
cat > "$LAUNCHER" <<EOF
#!/usr/bin/env bash
# NO-CODER launcher (installed by install.sh — do not edit by hand).
# Skip the xdg-desktop-portal file chooser so our app's CSS theme applies to
# file dialogs too. Safe on Omarchy — we don't need portal sandboxing.
export GTK_USE_PORTAL=0
exec python3 "$INSTALL_DIR/run.py" "\$@"
EOF
chmod +x "$LAUNCHER"
case ":$PATH:" in
*":$BIN_DIR:"*) ;;
*) warn "$BIN_DIR is not in your PATH — add it to your shell rc for CLI use (the .desktop launcher already uses an absolute path indirectly)." ;;
esac
# ---------- icon ----------
# Drop any previously-installed icons under the old/alternate theme locations,
# so the walker doesn't end up picking a stale version.
rm -f "$HICOLOR_DIR/scalable/apps/$APP_ID.svg"
for sz in 48 64 96 128 256 512; do
rm -f "$HICOLOR_DIR/${sz}x${sz}/apps/$APP_ID.png"
done
# Pick the best downscaler available — ImageMagick (modern "magick" or legacy
# "convert") gives crisp per-size PNGs. Fallback: install source at 256×256
# and let GTK scale on demand.
resize_png() {
local src="$1" dst="$2" size="$3"
if command -v magick >/dev/null 2>&1; then
magick "$src" -resize "${size}x${size}" "$dst"
elif command -v convert >/dev/null 2>&1; then
convert "$src" -resize "${size}x${size}" "$dst"
else
install -m 0644 "$src" "$dst"
fi
}
for sz in 48 64 96 128 256 512; do
dir="$HICOLOR_DIR/${sz}x${sz}/apps"
mkdir -p "$dir"
resize_png "$PKG_DIR/$APP_ID.png" "$dir/$APP_ID.png" "$sz"
done
say "Installed icons under $HICOLOR_DIR/{48,64,96,128,256,512}x*/apps/"
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
# hicolor/ without an index.theme won't regenerate a useful cache — ignore
# the "invalid" report. The PNGs are still discovered by direct lookup.
gtk-update-icon-cache -q -t "$HICOLOR_DIR" >/dev/null 2>&1 || true
fi
# ---------- .desktop file ----------
# The template uses @LAUNCHER@ in Exec= so we can substitute the absolute path
# to the user's launcher. Walker (and systemd-launched GUIs in general) runs
# with a minimal PATH that doesn't include ~/.local/bin, so a bare "Exec=nocoder"
# fails silently from the menu.
mkdir -p "$DESKTOP_DIR"
sed "s|@LAUNCHER@|$LAUNCHER|g" "$PKG_DIR/$APP_ID.desktop" > "$DESKTOP_DIR/$APP_ID.desktop"
chmod 0644 "$DESKTOP_DIR/$APP_ID.desktop"
say "Installed desktop entry to $DESKTOP_DIR/$APP_ID.desktop"
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database -q "$DESKTOP_DIR" || true
fi
if command -v desktop-file-validate >/dev/null 2>&1; then
desktop-file-validate "$DESKTOP_DIR/$APP_ID.desktop" || warn "desktop-file-validate reported warnings."
fi
# ---------- Hyprland windowrules ----------
say "Registering Hyprland windowrules in $HYPR_CONF"
mkdir -p "$(dirname "$HYPR_CONF")"
touch "$HYPR_CONF"
# Strip any previous block (idempotent) — but only if both markers are
# present as a closed pair. An unclosed BEGIN (from a crashed prior run)
# would otherwise cause awk to eat every subsequent line to EOF, including
# hand-edited rules beneath. Leave it alone and warn instead; the user can
# resolve manually, and the fresh block we append below still takes effect.
if grep -qxF "$MARK_BEGIN" "$HYPR_CONF" && ! grep -qxF "$MARK_END" "$HYPR_CONF"; then
warn "found unclosed '$MARK_BEGIN' block in $HYPR_CONF — leaving it intact (remove it manually if stale)."
elif grep -qxF "$MARK_BEGIN" "$HYPR_CONF"; then
tmp="$(mktemp)"
awk -v b="$MARK_BEGIN" -v e="$MARK_END" '
$0 == b { skip = 1; next }
skip && $0 == e { skip = 0; next }
!skip { print }
' "$HYPR_CONF" > "$tmp"
mv "$tmp" "$HYPR_CONF"
fi
# Append fresh block.
cat >> "$HYPR_CONF" <<EOF
$MARK_BEGIN
# NO-CODER — float, centered, at its design size.
windowrule = float on, match:class ^(dev\\.nocoder\\.NoCoder)$
windowrule = center on, match:class ^(dev\\.nocoder\\.NoCoder)$
windowrule = size 1280 880, match:class ^(dev\\.nocoder\\.NoCoder)$
$MARK_END
EOF
if command -v hyprctl >/dev/null 2>&1 && [[ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
say "Reloading Hyprland"
hyprctl reload >/dev/null
else
warn "hyprctl unavailable or Hyprland not running — rules will load on next session."
fi
# Walker caches its app list — restart so new installs show up immediately.
# (Omarchy ships a helper that restarts elephant.service + walker in one go.)
if command -v omarchy-restart-walker >/dev/null 2>&1; then
say "Restarting walker so the new entry is discoverable"
omarchy-restart-walker >/dev/null 2>&1 || true
fi
cat <<EOF
${GREEN}NO-CODER installed.${RESET}
${DIM}${RESET} App files: $INSTALL_DIR
${DIM}${RESET} Launcher: $LAUNCHER
${DIM}${RESET} Desktop: $DESKTOP_DIR/$APP_ID.desktop
${DIM}${RESET} Icon: $HICOLOR_DIR/{48,64,96,128,256,512}x*/apps/$APP_ID.png
${DIM}${RESET} Windowrules appended to $HYPR_CONF
Open the walker (Super+Space) and search for "NO-CODER".
Your git clone is no longer needed — feel free to delete it, or keep it to
'git pull && bash install.sh' for updates.
EOF

2
nocoder/__init__.py Normal file
View file

@ -0,0 +1,2 @@
"""NO-CODER — GTK4 + libadwaita desktop app for batch ffmpeg transcoding to Apple ProRes."""
__version__ = "0.1.0"

405
nocoder/app.py Normal file
View file

@ -0,0 +1,405 @@
"""Adw.Application entry point. Installs the CSS provider and opens the main window."""
from __future__ import annotations
import re
from pathlib import Path
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, Gio, GLib, Gtk
from .window import MainWindow
APP_ID = "dev.nocoder.NoCoder"
# ---------- shared parser helpers ----------
def _iter_lines(path: Path):
"""Yield stripped, non-empty, non-comment lines from `path` (utf-8).
Returns an empty iterator on OSError so callers don't need their own
try/except. Lines beginning with `#` are treated as comments.
"""
try:
raw = path.read_text(encoding="utf-8")
except OSError:
return
for line in raw.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
yield stripped
def _dequote(v: str) -> str:
"""If `v` is quoted (`"foo"` / `'foo'`), return the content; else return v.
Used by colors.toml + alacritty.toml + ghostty.conf parsers every value
in those files may be quoted, but their hex colours start with `#` which
we MUST NOT trim as if it were an inline TOML comment when the value is
quoted.
"""
if v[:1] in ('"', "'"):
end = v.find(v[0], 1)
if end > 0:
return v[1:end]
return v
def _fill_accent_fallback(palette: dict) -> None:
"""If `palette` has no `accent` key, fill it from the most useful ANSI
colour available (blue magenta cyan in that order). Mutates in place.
"""
if "accent" in palette:
return
for k in ("color4", "color5", "color6"):
if k in palette:
palette["accent"] = palette[k]
return
def _read_colors_toml(path: Path) -> dict:
"""Minimal parser for Omarchy's colors.toml — flat `key = "value"` lines only.
Avoids a hard dep on Python 3.11's `tomllib`; the file format here is
trivial enough to parse directly and the parser doesn't have to handle
nested tables or arrays (Omarchy's schema is flat).
"""
result: dict[str, str] = {}
for line in _iter_lines(path):
if "=" not in line:
continue
k, _, v = line.partition("=")
k = k.strip()
v = v.strip()
if v[:1] in ('"', "'"):
v = _dequote(v)
elif "#" in v:
# Unquoted value: trailing `# comment` is real, strip it.
v = v.split("#", 1)[0].strip()
if k and v:
result[k] = v
return result
_ALACRITTY_NORMAL_TO_ANSI = {
"black": "color0", "red": "color1", "green": "color2", "yellow": "color3",
"blue": "color4", "magenta": "color5", "cyan": "color6", "white": "color7",
}
_HEX_COLOR = re.compile(r"#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?")
def _read_alacritty_palette(path: Path) -> dict:
"""Extract primary bg/fg AND [colors.normal] indices from alacritty.toml.
Returns keys compatible with `colors.toml`: `background`, `foreground`,
and `color0`..`color7` (mapped from `red`, `green`, ... inside
`[colors.normal]`). `[colors.bright]` is used only as a fallback for a
brighter `accent` pick.
"""
result: dict[str, str] = {}
section = None
for line in _iter_lines(path):
if line.startswith("[") and line.endswith("]"):
section = line[1:-1]
continue
if section not in ("colors.primary", "colors.normal", "colors.bright") or "=" not in line:
continue
k, _, v = line.partition("=")
k = k.strip()
v = _dequote(v.strip())
if not v:
continue
if section == "colors.primary" and k in ("background", "foreground"):
result[k] = v
elif section == "colors.normal" and k in _ALACRITTY_NORMAL_TO_ANSI:
result[_ALACRITTY_NORMAL_TO_ANSI[k]] = v
elif section == "colors.bright" and k in _ALACRITTY_NORMAL_TO_ANSI:
# Only fill a bright slot if the normal one didn't already land —
# lets themes that only define bright still produce something.
result.setdefault(_ALACRITTY_NORMAL_TO_ANSI[k], v)
_fill_accent_fallback(result)
return result
def _read_ghostty_palette(path: Path) -> dict:
"""Extract bg / fg / palette[0..15] from a ghostty.conf.
Format (per Omarchy's template):
background = #rrggbb
foreground = #rrggbb
palette = 0=#rrggbb
palette = 4=#rrggbb
"""
result: dict[str, str] = {}
for line in _iter_lines(path):
if "=" not in line:
continue
k, _, v = line.partition("=")
k = k.strip()
v = v.strip()
if k in ("background", "foreground"):
m = _HEX_COLOR.search(v)
if m:
result[k] = m.group(0)
elif k == "palette":
# value is "N=#rrggbb"
idx, _, hexval = v.partition("=")
idx = idx.strip()
if idx.isdigit():
m = _HEX_COLOR.search(hexval.strip())
if m:
result[f"color{idx}"] = m.group(0)
_fill_accent_fallback(result)
return result
def _read_kitty_palette(path: Path) -> dict:
"""Extract bg / fg / colorN / active_border_color from a kitty.conf.
Kitty uses whitespace-separated `key value` lines; Omarchy's template
additionally sets `active_border_color` to the theme's accent, which we
mine as the accent if nothing better is available.
"""
result: dict[str, str] = {}
for line in _iter_lines(path):
# Keep the first two whitespace-delimited tokens.
parts = line.split(None, 2)
if len(parts) < 2:
continue
k, v = parts[0], parts[1]
m = _HEX_COLOR.search(v)
if not m:
continue
hexval = m.group(0)
if k in ("background", "foreground"):
result[k] = hexval
elif k == "active_border_color":
result["accent"] = hexval
elif k.startswith("color") and k[5:].isdigit():
result[k] = hexval
_fill_accent_fallback(result)
return result
def _contrast_fg(hex_color: str, light: str = "#ffffff", dark: str = "#111111") -> str:
"""Return `light` or `dark` based on perceived luminance of `hex_color`.
Used to pick accent-fg / destructive-fg / success-fg etc. a saturated
accent background needs matching text regardless of whether the theme is
light or dark overall.
"""
if not hex_color.startswith("#"):
return dark
h = hex_color.lstrip("#")
if len(h) == 3:
h = "".join(c * 2 for c in h)
if len(h) != 6:
return dark
try:
r = int(h[0:2], 16)
g = int(h[2:4], 16)
b = int(h[4:6], 16)
except ValueError:
return dark
# Rec. 601 weighted luminance (simple & good enough for UI contrast).
lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return dark if lum > 0.55 else light
def _synthesize_theme_css(palette: dict) -> str:
"""Build a full libadwaita-token CSS from an Omarchy palette.
Unlike earlier revisions, this now also synthesises `accent_*`,
`destructive_*`, `success_*`, `warning_*` and `error_*` from the theme's
own `accent` + ANSI `color0..color7`, so the app's accents and semantic
colours adhere to whichever theme the user has set.
"""
bg = palette["background"]
fg = palette["foreground"]
# Accent: prefer the theme's own accent, fall back to ANSI blue/magenta/cyan.
accent = palette.get("accent") or palette.get("color4") or palette.get("color5") or palette.get("color6") or fg
accent_fg = _contrast_fg(accent, light=fg, dark=bg)
# Semantic colours — fall back to the accent if a slot is missing so we
# never fail to define a libadwaita token.
danger = palette.get("color1") or accent
success = palette.get("color2") or accent
warning = palette.get("color3") or accent
info = palette.get("color4") or accent
# GTK4 CSS `shade()` is reliable on @named-color references but parses
# inconsistently against inline hex literals. Define a private base token
# so the subsequent shade() calls get a named reference in all GTK
# versions — avoids silent fallback to libadwaita defaults for the
# view/headerbar/card/sidebar bg tokens.
return f"""
@define-color _nocoder_base {bg};
@define-color window_bg_color {bg};
@define-color window_fg_color {fg};
@define-color view_bg_color shade(@_nocoder_base, 0.93);
@define-color view_fg_color {fg};
@define-color dialog_bg_color {bg};
@define-color dialog_fg_color {fg};
@define-color popover_bg_color {bg};
@define-color popover_fg_color {fg};
@define-color headerbar_bg_color shade(@_nocoder_base, 1.12);
@define-color headerbar_fg_color {fg};
@define-color card_bg_color shade(@_nocoder_base, 0.93);
@define-color card_fg_color {fg};
@define-color sidebar_bg_color shade(@_nocoder_base, 0.93);
@define-color sidebar_fg_color {fg};
@define-color accent_color {accent};
@define-color accent_bg_color {accent};
@define-color accent_fg_color {accent_fg};
@define-color destructive_bg_color {danger};
@define-color destructive_fg_color {_contrast_fg(danger, light=fg, dark=bg)};
@define-color success_bg_color {success};
@define-color success_fg_color {_contrast_fg(success, light=fg, dark=bg)};
@define-color warning_bg_color {warning};
@define-color warning_fg_color {_contrast_fg(warning, light=fg, dark=bg)};
@define-color error_bg_color {danger};
@define-color error_fg_color {_contrast_fg(danger, light=fg, dark=bg)};
"""
# Omarchy's canonical per-theme palette. Every stock theme ships `colors.toml`
# (keys: background, foreground, accent, color0..color15). A handful of custom
# themes (e.g., "lumon") additionally ship a full `gtk.css` with libadwaita
# tokens pre-mapped; when present we prefer that file verbatim. Otherwise we
# synthesize a minimal libadwaita palette from colors.toml below.
#
# Both paths resolve through Omarchy's `current/theme` symlink, so a
# `omarchy-theme-set <name>` followed by an app relaunch picks up the change.
OMARCHY_THEME_DIR = Path.home() / ".config" / "omarchy" / "current" / "theme"
OMARCHY_GTK_CSS = OMARCHY_THEME_DIR / "gtk.css"
OMARCHY_COLORS_TOML = OMARCHY_THEME_DIR / "colors.toml"
OMARCHY_GHOSTTY_CONF = OMARCHY_THEME_DIR / "ghostty.conf"
OMARCHY_ALACRITTY_TOML = OMARCHY_THEME_DIR / "alacritty.toml"
OMARCHY_KITTY_CONF = OMARCHY_THEME_DIR / "kitty.conf"
class NoCoderApplication(Adw.Application):
def __init__(self) -> None:
super().__init__(
application_id=APP_ID,
flags=Gio.ApplicationFlags.HANDLES_OPEN,
)
self._window: MainWindow | None = None
def do_startup(self) -> None:
Adw.Application.do_startup(self)
# Let the Omarchy theme dictate light/dark via its libadwaita tokens
# rather than forcing dark — the app used to pin FORCE_DARK back when
# the palette was hardcoded Tokyo Night. Keep DEFAULT so a light theme
# like catppuccin-latte or flexoki-light renders correctly.
self._install_omarchy_theme_css()
self._install_css()
# If a previous session crashed mid-encode, surface the orphan path so
# the user knows where the partial .mov sits. We don't auto-delete —
# could be a real file that happens to share the marker's name.
from .encoder import check_orphan_encode # local import to avoid cycle on import order
orphan = check_orphan_encode()
if orphan is not None:
import sys
print(
f"[nocoder] previous encode left an unfinished file: {orphan}\n"
f" (delete it manually if it's incomplete)",
file=sys.stderr,
flush=True,
)
def do_activate(self) -> None:
if self._window is None:
self._window = MainWindow(self)
self._window.present()
def do_open(self, files, _n_files, _hint) -> None:
self.do_activate()
if self._window is None:
return
paths = []
for f in files:
p = f.get_path() if f is not None else None
if p:
paths.append(p)
if paths:
self._window._add_paths(paths)
def _install_omarchy_theme_css(self) -> None:
"""Make the app track the active Omarchy theme.
Strategy:
1. If the theme provides a full `gtk.css` (rare only some custom
themes like "lumon"), load it verbatim.
2. Otherwise synthesize the libadwaita named tokens from the
theme's `colors.toml` (shipped by every stock Omarchy theme).
3. If neither is present, no-op libadwaita defaults apply.
The provider is installed at `PRIORITY_THEME`, below our style.css at
`PRIORITY_APPLICATION`, so our CSS can override anything token-derived
(the brand accent, semantic colours) while leaving bg / fg / borders
/ popover / dialog chrome cascading from the theme.
"""
css_text = None
if OMARCHY_GTK_CSS.exists():
try:
css_text = OMARCHY_GTK_CSS.read_text(encoding="utf-8")
except OSError:
css_text = None
if css_text is None and OMARCHY_COLORS_TOML.exists():
palette = _read_colors_toml(OMARCHY_COLORS_TOML)
if palette.get("background") and palette.get("foreground"):
css_text = _synthesize_theme_css(palette)
# If no colors.toml, try each terminal config in turn — Omarchy
# generates all three for any themed terminal. A user who's wiped
# alacritty from their system might still have ghostty or kitty.
for path, reader in (
(OMARCHY_GHOSTTY_CONF, _read_ghostty_palette),
(OMARCHY_ALACRITTY_TOML, _read_alacritty_palette),
(OMARCHY_KITTY_CONF, _read_kitty_palette),
):
if css_text is not None:
break
if not path.exists():
continue
palette = reader(path)
if palette.get("background") and palette.get("foreground"):
css_text = _synthesize_theme_css(palette)
if not css_text:
return
provider = Gtk.CssProvider()
try:
provider.load_from_data(css_text.encode("utf-8"))
except GLib.Error:
return
display = Gdk.Display.get_default()
if display is not None:
Gtk.StyleContext.add_provider_for_display(
display, provider, Gtk.STYLE_PROVIDER_PRIORITY_THEME,
)
def _install_css(self) -> None:
# Resolve style.css relative to the package root.
css_path = Path(__file__).resolve().parent.parent / "style.css"
if not css_path.exists():
return
provider = Gtk.CssProvider()
provider.load_from_path(str(css_path))
display = Gdk.Display.get_default()
if display is not None:
Gtk.StyleContext.add_provider_for_display(
display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)

131
nocoder/data.py Normal file
View file

@ -0,0 +1,131 @@
"""ProRes profile map, video extensions, formatters, size/time estimators.
Mirrors design_handoff_prowrap/src/data.jsx and the profile map from prowrap-yad.sh.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Profile:
id: str
name: str
desc: str
mbps: int
pid: int
alpha: bool = False
# Relative speed factor used for "estimated encode time" (matches footer.jsx).
speed_factor: float = 0.9
PROFILES: list[Profile] = [
Profile("proxy", "Proxy", "45 Mb/s — fast, small, offline edit", 45, 0, speed_factor=0.3),
Profile("lt", "LT", "102 Mb/s — lightweight delivery", 102, 1, speed_factor=0.5),
Profile("standard", "Standard", "147 Mb/s — general mastering", 147, 2, speed_factor=0.7),
Profile("hq", "HQ", "220 Mb/s — high-quality mastering", 220, 3, speed_factor=0.9),
Profile("4444", "4444", "330 Mb/s — 4:4:4 + alpha", 330, 4, alpha=True, speed_factor=1.3),
Profile("4444xq", "4444 XQ", "500 Mb/s — maximum 4:4:4 + alpha", 500, 5, alpha=True, speed_factor=1.7),
]
PROFILES_BY_ID: dict[str, Profile] = {p.id: p for p in PROFILES}
VIDEO_EXTENSIONS: frozenset[str] = frozenset({
# Common consumer / editorial container formats
".mp4", ".mov", ".m4v", ".mkv", ".avi", ".mts", ".m2ts",
".webm", ".mpeg", ".mpg", ".3gp", ".3g2",
# Professional camera container — MXF covers Canon XF-AVC, Sony XDCAM,
# Panasonic AVC-Intra / P2. ffmpeg decodes these natively on stock builds.
#
# NOT in this list (deliberate): .crm (Canon Cinema RAW Light), .braw
# (Blackmagic RAW), .r3d (RED), .ari (Arri RAW). All are proprietary and
# require vendor SDKs that ffmpeg does not ship. Including them here
# would have them land in the queue only to fail at encode with a cryptic
# decoder error, which is worse UX than ignoring them on drop. Users
# shooting those formats should first transcode via the vendor tool
# (Canon Cinema RAW Development, Blackmagic RAW Player, REDCINE-X, Arri
# Meta Extract) into MXF or ProRes.
".mxf",
})
def is_video_path(path: str) -> bool:
lower = path.lower()
return any(lower.endswith(ext) for ext in VIDEO_EXTENSIONS)
# Subdirectory names to SKIP when recursively walking a dropped folder or
# camera card. The names match case-insensitively.
#
# Pro cameras write both a master clip and a low-res "proxy" alongside it,
# typically in a sibling directory with the same base filename. If we walk
# into those proxy dirs, the queue fills with low-res duplicates that look
# like real clips but are ~5-10% of the master's bitrate. Users not paying
# attention would transcode the proxies and lose quality.
#
# Known layouts:
# Sony XAVC: PRIVATE/M4ROOT/CLIP/*.MXF + SUB/*.MP4 (proxy)
# + THMBNL/*.JPG (thumbnails)
# + GENERAL/* (metadata)
# Canon XF-AVC: CONTENTS/CLIPS001/*.MXF + SUB/*.MP4
# Panasonic P2: CONTENTS/VIDEO/*.MXF + PROXY/*.MP4
# + ICON/*.BMP (thumbs)
# + VOICE/* (audio notes)
# Generic DSLR: DCIM/* (nothing to skip)
#
# If a filmmaker intentionally drops the SUB/ directory specifically, it'd
# still work — we only prune when recursing INTO a parent folder.
PROXY_DIRNAMES: frozenset[str] = frozenset({
# Sony / Canon proxies
"sub",
# Panasonic P2 proxies + metadata
"proxy", "icon", "voice",
# Thumbnail directories across vendors
"thmbnl", "thumbs", "thumb", "thumbnail", "thumbnails", "preview", "previews",
})
def is_proxy_dirname(name: str) -> bool:
return name.lower() in PROXY_DIRNAMES
def pick_pixel_format(profile_id: str, alpha: bool) -> str:
"""yuv422p10le for non-4444; yuv444p10le for 4444; yuva444p10le if 4444+alpha."""
profile = PROFILES_BY_ID[profile_id]
if profile.pid >= 4:
return "yuva444p10le" if alpha else "yuv444p10le"
return "yuv422p10le"
def format_bytes(b: float) -> str:
if b < 1024:
return f"{int(b)} B"
if b < 1024 * 1024:
return f"{b / 1024:.0f} KB"
if b < 1024 * 1024 * 1024:
return f"{b / (1024 * 1024):.0f} MB"
return f"{b / (1024 * 1024 * 1024):.2f} GB"
def format_duration(seconds: float) -> str:
seconds = max(0, int(seconds))
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
if h:
return f"{h}:{m:02d}:{s:02d}"
return f"{m}:{s:02d}"
def estimate_output_bytes(duration_s: float, mbps: int) -> float:
"""Video bitrate × duration + PCM 16-bit stereo audio (~1.411 Mb/s)."""
if not duration_s or duration_s <= 0:
return 0
video_bits = mbps * 1_000_000 * duration_s
audio_bits = 1_411_000 * duration_s
return (video_bits + audio_bits) / 8
def estimate_encode_seconds(duration_s: float, profile_id: str) -> float:
"""Rough heuristic used for the UI's Est. encode time. Matches footer.jsx."""
return duration_s * PROFILES_BY_ID[profile_id].speed_factor

497
nocoder/encoder.py Normal file
View file

@ -0,0 +1,497 @@
"""ffprobe metadata + ffmpeg encode with live -progress parsing.
The encode command mirrors prowrap-yad.sh exactly:
ffmpeg -hide_banner -loglevel error -y -i SRC \
-map 0:v:0 -map 0:a? \
-c:v prores_ks -profile:v <profile> -pix_fmt <pf> [-alpha_bits 16] \
-c:a pcm_s16le -f mov -movflags +use_metadata_tags OUT
"""
from __future__ import annotations
import json
import os
import shlex
import subprocess
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional
from .data import PROFILES_BY_ID, pick_pixel_format
from .hwaccel import get_hwaccel
FFMPEG = "/usr/bin/ffmpeg"
FFPROBE = "/usr/bin/ffprobe"
# Marker file that records the currently-encoding output path. Created when an
# encode starts, removed on success/failure/cancel. If the app is force-killed
# (SIGKILL, OS crash) mid-encode the marker survives — `check_orphan_encode`
# at startup detects this and surfaces the partial file's path so the user
# can clean up.
ACTIVE_ENCODE_FILE = (
Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
/ "nocoder"
/ "active.json"
)
def _mark_encode_started(out_path: str) -> None:
try:
ACTIVE_ENCODE_FILE.parent.mkdir(parents=True, exist_ok=True)
ACTIVE_ENCODE_FILE.write_text(json.dumps({"out_path": out_path}) + "\n")
except OSError:
pass
def _mark_encode_finished() -> None:
try:
ACTIVE_ENCODE_FILE.unlink()
except FileNotFoundError:
pass
except OSError:
pass
def check_orphan_encode() -> Optional[str]:
"""If a previous encode died ungracefully, return its output path.
Always clears the marker after inspection so we don't repeatedly warn
on subsequent launches. Returns None if no marker existed, or if the
marker pointed at a path that no longer exists (cleanly removed already).
"""
if not ACTIVE_ENCODE_FILE.exists():
return None
out_path = None
try:
data = json.loads(ACTIVE_ENCODE_FILE.read_text())
candidate = data.get("out_path")
if isinstance(candidate, str) and os.path.isfile(candidate):
out_path = candidate
except (OSError, json.JSONDecodeError):
pass
_mark_encode_finished()
return out_path
def detect_prores_encoder() -> str:
"""Return 'ks', 'plain', or 'none' based on available ffmpeg encoders."""
try:
out = subprocess.run(
[FFMPEG, "-hide_banner", "-encoders"],
capture_output=True, text=True, timeout=5, check=False,
).stdout
except (FileNotFoundError, subprocess.TimeoutExpired):
return "none"
if " prores_ks " in " " + out + " ":
return "ks"
# Match either standalone 'prores' or 'prores_aw' (both register as 'prores').
for line in out.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[1] in ("prores", "prores_aw"):
return "plain"
return "none"
@dataclass
class Metadata:
duration: float = 0.0
width: int = 0
height: int = 0
codec: str = ""
fps: float = 0.0
alpha: bool = False
# Absolute stream indices (0-based across all streams in the file) of
# every audio stream with a known codec, in source order. Pro cameras
# (Canon C300/C500, Sony FX6) record 4 separate mono PCM streams for
# boom / lav / ambient / scratch — editorial expects all of them
# preserved as distinct tracks in the output .mov, so we map each by
# absolute index. iPhone-style sidecar streams (codec_name=unknown) are
# skipped. Empty list = silent video.
audio_stream_indexes: list[int] = field(default_factory=list)
@property
def resolution(self) -> str:
if self.width and self.height:
return f"{self.width}×{self.height}"
return ""
def probe_metadata(path: str) -> Metadata:
"""Run ffprobe synchronously. Callers should invoke from a worker thread.
Walks every stream in the source so we can both:
- fill Metadata fields from the first video stream (width, height, fps,
codec, alpha), and
- find the first *usable* audio stream (known codec_name) so encode
time can map it by absolute index instead of the positional glob.
"""
meta = Metadata()
try:
proc = subprocess.run(
[
FFPROBE, "-v", "error",
"-show_entries",
"stream=index,codec_type,codec_name,width,height,r_frame_rate,pix_fmt:format=duration",
"-of", "json",
path,
],
capture_output=True, text=True, timeout=15, check=False,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return meta
if proc.returncode != 0 or not proc.stdout:
return meta
try:
data = json.loads(proc.stdout)
except json.JSONDecodeError:
return meta
fmt = data.get("format") or {}
try:
meta.duration = float(fmt.get("duration") or 0.0)
except (TypeError, ValueError):
meta.duration = 0.0
seen_video = False
for stream in data.get("streams") or []:
stype = stream.get("codec_type") or ""
codec = (stream.get("codec_name") or "").strip().lower()
if stype == "video" and not seen_video:
seen_video = True
meta.codec = _human_codec(stream.get("codec_name") or "")
try:
meta.width = int(stream.get("width") or 0)
meta.height = int(stream.get("height") or 0)
except (TypeError, ValueError):
pass
rate = stream.get("r_frame_rate") or "0/1"
meta.fps = _parse_rate(rate)
pix_fmt = (stream.get("pix_fmt") or "").lower()
meta.alpha = _pix_fmt_has_alpha(pix_fmt)
elif stype == "audio" and codec and codec not in ("unknown", "none"):
idx = stream.get("index")
if isinstance(idx, int):
meta.audio_stream_indexes.append(idx)
return meta
# Concrete pixel-format tokens that carry an alpha channel. The earlier
# heuristic (`"a" in pix_fmt.split("p", 1)[0]`) misfired on grayscale formats
# because "gray" contains the letter 'a'.
_ALPHA_PIX_FMT_TOKENS = (
"yuva", "rgba", "argb", "abgr", "bgra", "rgb32", "bgr32",
)
def _pix_fmt_has_alpha(pix_fmt: str) -> bool:
if not pix_fmt:
return False
if any(tok in pix_fmt for tok in _ALPHA_PIX_FMT_TOKENS):
return True
# `ya8`, `ya16le`, etc. — grayscale with alpha. Match "ya" followed by a
# digit so we don't false-positive on "yay" or similar nonsense.
return len(pix_fmt) > 2 and pix_fmt.startswith("ya") and pix_fmt[2].isdigit()
def _parse_rate(rate: str) -> float:
try:
num, den = rate.split("/", 1)
n, d = float(num), float(den)
if d == 0:
return 0.0
return round(n / d, 3)
except (ValueError, ZeroDivisionError):
return 0.0
_CODEC_NAMES = {
"h264": "H.264", "hevc": "HEVC", "prores": "ProRes", "vp9": "VP9", "av1": "AV1",
"mpeg4": "MPEG-4", "mpeg2video": "MPEG-2", "mjpeg": "MJPEG", "dnxhd": "DNxHD",
"vc1": "VC-1", "flv1": "FLV1",
}
def _human_codec(name: str) -> str:
return _CODEC_NAMES.get(name.lower(), name.upper() if name else "")
def build_command(
src: str,
out: str,
profile_id: str,
alpha: bool,
encoder: str,
audio_indexes: Optional[list[int]] = None,
audio_bits: int = 16,
) -> list[str]:
"""Assemble the ffmpeg command list for a single encode.
`audio_indexes` is the absolute stream indices of every known-codec audio
track in the source (see `probe_metadata`). Each one is mapped into the
output as a separate track pro cameras record 4 separate mono PCM
streams that editorial wants preserved as distinct tracks, not collapsed.
audio_indexes == list of ints `-map 0:<i>` for each (what we want)
audio_indexes == [] silent output (no audio map, no -c:a)
audio_indexes is None fallback `-map 0:a:0?` (first audio,
optional) for ad-hoc callers who
haven't probed yet
"""
profile = PROFILES_BY_ID[profile_id]
pix_fmt = pick_pixel_format(profile_id, alpha)
cmd: list[str] = [
FFMPEG, "-hide_banner", "-loglevel", "error", "-y",
"-nostdin",
]
hw = get_hwaccel()
if hw:
# ffmpeg silently falls back to CPU decode for codecs the GPU can't
# handle (MJPEG, ProRes input, etc.), so unconditional -hwaccel is safe.
cmd += ["-hwaccel", hw]
cmd += ["-i", src, "-map", "0:v:0"]
if audio_indexes is None:
# No probe info → safe fallback (first known audio, optional).
cmd += ["-map", "0:a:0?"]
has_audio = True
elif audio_indexes:
for idx in audio_indexes:
cmd += ["-map", f"0:{idx}"]
has_audio = True
else:
has_audio = False
if encoder == "ks":
cmd += ["-c:v", "prores_ks", "-profile:v", profile.id, "-pix_fmt", pix_fmt]
if alpha and profile.pid >= 4:
cmd += ["-alpha_bits", "16"]
else:
cmd += ["-c:v", "prores", "-profile:v", str(profile.pid), "-pix_fmt", pix_fmt]
if has_audio:
# Single -c:a spec applies to every mapped audio stream; each stays as
# its own track in the output .mov, just re-encoded. 24-bit preserves
# pro-camera dynamic range; 16-bit is the editorial default.
audio_codec = "pcm_s24le" if audio_bits == 24 else "pcm_s16le"
cmd += ["-c:a", audio_codec]
cmd += [
"-f", "mov",
"-movflags", "+use_metadata_tags",
"-progress", "pipe:1",
out,
]
return cmd
def format_preview_command(src_name: str, out_path: str, profile_id: str, alpha: bool, audio_bits: int = 16) -> str:
"""Pretty multi-line preview for the ffmpeg command box. Uses prores_ks always.
The real command maps each known audio stream by absolute index; the
preview shows `0:a?` (glob-all) for brevity the runtime behaviour is
equivalent when every audio stream is known-codec.
"""
profile = PROFILES_BY_ID[profile_id]
pix_fmt = pick_pixel_format(profile_id, alpha)
alpha_flag = " -alpha_bits 16" if (alpha and profile.pid >= 4) else ""
hw = get_hwaccel()
hw_line = f" -hwaccel {hw} \\\n" if hw else ""
audio_codec = "pcm_s24le" if audio_bits == 24 else "pcm_s16le"
return (
"ffmpeg -hide_banner -y \\\n"
+ hw_line
+ f' -i "{src_name}" \\\n'
" -map 0:v:0 -map 0:a? \\\n"
f" -c:v prores_ks -profile:v {profile.id} \\\n"
f" -pix_fmt {pix_fmt}{alpha_flag} \\\n"
f" -c:a {audio_codec} \\\n"
" -movflags +use_metadata_tags \\\n"
f' "{out_path}"'
)
def plan_output_path(src: str, out_dir: str, naming: str, profile_id: str) -> str:
"""Output path rules from prowrap-yad.sh: keep vs suffix, with ' (N)' disambiguation."""
stem = Path(src).stem
if naming == "suffix":
base = f"{stem}_prores_{profile_id}"
else:
base = stem
candidate = Path(out_dir) / f"{base}.mov"
if not candidate.exists():
return str(candidate)
n = 1
while True:
trial = Path(out_dir) / f"{base} ({n}).mov"
if not trial.exists():
return str(trial)
n += 1
@dataclass
class EncodeJob:
src: str
out: str
duration: float
on_progress: Callable[[float], None] # 0..1 (file-local)
on_done: Callable[[bool, Optional[str]], None] # (success, error_text)
# ffmpeg's `-progress` emits `speed=1.5x` every ~1s; this callback
# surfaces that as a float (1.5 = encoding 1.5 seconds of source per
# second of wall time). Optional — None = caller doesn't care.
on_speed: Optional[Callable[[float], None]] = None
# Resolved at file-add time via probe_metadata. Threaded through so
# build_command can map each known audio stream by absolute index.
# Empty list = silent video; None = no probe info (safe fallback applies).
audio_stream_indexes: Optional[list[int]] = None
cancel_event: threading.Event = field(default_factory=threading.Event)
_proc: Optional[subprocess.Popen] = None
def cancel(self) -> None:
self.cancel_event.set()
proc = self._proc
if proc and proc.poll() is None:
try:
proc.terminate()
except Exception:
pass
def run_encode(job: EncodeJob, profile_id: str, alpha: bool, encoder: str, audio_bits: int = 16) -> None:
"""Blocking. Runs ffmpeg, streams progress lines, invokes callbacks."""
if job.cancel_event.is_set():
job.on_done(False, "cancelled")
return
cmd = build_command(job.src, job.out, profile_id, alpha, encoder, job.audio_stream_indexes, audio_bits)
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
except FileNotFoundError as e:
job.on_done(False, f"ffmpeg not found: {e}")
return
job._proc = proc
_mark_encode_started(job.out)
duration_us = max(1.0, (job.duration or 0) * 1_000_000)
last_pct = 0.0
assert proc.stdout is not None
try:
for raw in proc.stdout:
if job.cancel_event.is_set():
try:
proc.terminate()
except Exception:
pass
break
line = raw.strip()
if not line or "=" not in line:
continue
key, _, val = line.partition("=")
if key == "out_time_us" and val.isdigit():
pct = min(1.0, int(val) / duration_us)
if pct - last_pct >= 0.005 or pct >= 1.0:
last_pct = pct
job.on_progress(pct)
elif key == "out_time_ms" and val.isdigit():
# out_time_ms is actually in microseconds in ffmpeg (historical naming).
pct = min(1.0, int(val) / duration_us)
if pct - last_pct >= 0.005 or pct >= 1.0:
last_pct = pct
job.on_progress(pct)
elif key == "speed" and val.endswith("x") and job.on_speed is not None:
# ffmpeg writes `speed=1.5x` (or `speed=N/A` while warming up).
try:
spd = float(val[:-1])
except ValueError:
pass
else:
job.on_speed(spd)
elif key == "progress" and val == "end":
job.on_progress(1.0)
# If we broke out on cancel, drain any remaining stdout so ffmpeg isn't
# blocked on a full pipe before it can respond to SIGTERM.
if job.cancel_event.is_set():
try:
proc.stdout.read()
except Exception:
pass
# Short wait after cancel/finish — 5s is plenty. If still alive, SIGKILL
# and a brief second wait so the zombie is reaped before we inspect
# returncode.
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
try:
proc.kill()
except Exception:
pass
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
pass
if job.cancel_event.is_set():
_safe_unlink(job.out)
job.on_done(False, "cancelled")
return
if proc.returncode == 0 and _nonempty_file(job.out):
_copy_mtime(job.src, job.out)
job.on_done(True, None)
else:
err = ""
if proc.stderr is not None:
try:
err = proc.stderr.read() or ""
except Exception:
err = ""
_safe_unlink(job.out)
job.on_done(False, (err.strip() or f"ffmpeg exited {proc.returncode}"))
finally:
# Always close stdout/stderr so FDs aren't leaked on long queues or
# mid-stream cancels. Safe to call on already-closed streams.
for stream in (proc.stdout, proc.stderr):
if stream is not None:
try:
stream.close()
except Exception:
pass
# Clear the orphan marker — encode reached a terminal state, success
# or failure. SIGKILL/crash is the only path that leaves it behind.
_mark_encode_finished()
def _nonempty_file(path: str) -> bool:
try:
return os.path.isfile(path) and os.path.getsize(path) > 0
except OSError:
return False
def _safe_unlink(path: str) -> None:
try:
os.unlink(path)
except OSError:
pass
def _copy_mtime(src: str, dst: str) -> None:
try:
st = os.stat(src)
os.utime(dst, (st.st_atime, st.st_mtime))
except OSError:
pass
def preview_shell_command(src: str, out: str, profile_id: str, alpha: bool, encoder: str) -> str:
"""For copy-to-clipboard style usage; kept simple — not used by UI preview box."""
return " ".join(shlex.quote(x) for x in build_command(src, out, profile_id, alpha, encoder))

374
nocoder/footer.py Normal file
View file

@ -0,0 +1,374 @@
"""Footer / action bar. Three variants: ready, encoding, complete.
Emits:
encode-requested ()
cancel-requested ()
reveal-requested ()
"""
from __future__ import annotations
from typing import Optional
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("GObject", "2.0")
from gi.repository import GObject, Gtk, Pango
from .data import (
PROFILES_BY_ID,
estimate_encode_seconds,
format_bytes,
format_duration,
)
from .queue_pane import FileEntry
class Footer(Gtk.Box):
__gtype_name__ = "NoCoderFooter"
__gsignals__ = {
"encode-requested": (GObject.SignalFlags.RUN_LAST, None, ()),
"cancel-requested": (GObject.SignalFlags.RUN_LAST, None, ()),
"reveal-requested": (GObject.SignalFlags.RUN_LAST, None, ()),
}
def __init__(self) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL)
self.add_css_class("footer-bar")
self.set_hexpand(True)
self._state = "ready" # ready | encoding | complete
self._files: list[FileEntry] = []
self._profile_id = "hq"
self._overall = 0.0
self._current_idx = 0
self._speed: Optional[float] = None
self._build()
# ---------- external API ----------
def update(self, state: str, files: list[FileEntry], profile_id: str,
overall: float, current_idx: int,
speed: 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._render()
# ---------- build ----------
def _build(self) -> None:
# Build both variants once, toggle visibility in _render.
self._ready_box = self._build_ready()
self._encoding_box = self._build_encoding()
self._complete_box = self._build_complete()
self.append(self._ready_box)
self.append(self._encoding_box)
self.append(self._complete_box)
self._render()
def _build_ready(self) -> Gtk.Box:
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=20)
box.set_hexpand(True)
stats = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=28)
stats.set_hexpand(True)
self._stat_files = _make_stat("Files", "0")
stats.append(self._stat_files.root)
stats.append(_divider())
self._stat_dur = _make_stat("Total duration", "", small=True)
stats.append(self._stat_dur.root)
stats.append(_divider())
self._stat_out_box = _make_io_stat("Estimated output")
stats.append(self._stat_out_box.root)
stats.append(_divider())
self._stat_eta = _make_stat("Est. encode time", "", small=True, with_clock=True)
stats.append(self._stat_eta.root)
box.append(stats)
self._encode_btn = Gtk.Button()
self._encode_btn.add_css_class("encode-cta")
self._encode_btn.set_child(_icon_label_light("media-playback-start-symbolic", "Encode"))
self._encode_btn.connect("clicked", lambda _b: self.emit("encode-requested"))
box.append(self._encode_btn)
return box
def _build_encoding(self) -> Gtk.Box:
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14)
box.set_hexpand(True)
center = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
center.set_hexpand(True)
title_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self._enc_title = Gtk.Label(xalign=0)
self._enc_title.add_css_class("progress-title")
self._enc_title.set_ellipsize(Pango.EllipsizeMode.END)
self._enc_title.set_hexpand(True)
title_row.append(self._enc_title)
self._enc_pct = Gtk.Label(xalign=1.0)
self._enc_pct.add_css_class("progress-title")
self._enc_pct.add_css_class("pct")
title_row.append(self._enc_pct)
center.append(title_row)
self._enc_progress = Gtk.ProgressBar()
self._enc_progress.add_css_class("overall-progress")
center.append(self._enc_progress)
status_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self._enc_status_left = Gtk.Label(xalign=0)
self._enc_status_left.add_css_class("progress-status")
self._enc_status_left.set_use_markup(True)
self._enc_status_left.set_hexpand(True)
status_row.append(self._enc_status_left)
self._enc_eta = Gtk.Label(xalign=1.0)
self._enc_eta.add_css_class("progress-status")
status_row.append(self._enc_eta)
center.append(status_row)
box.append(center)
cancel = Gtk.Button()
cancel.add_css_class("cancel-btn")
cancel.set_child(_icon_label("process-stop-symbolic", "Cancel"))
cancel.connect("clicked", lambda _b: self.emit("cancel-requested"))
box.append(cancel)
return box
def _build_complete(self) -> Gtk.Box:
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=20)
box.set_hexpand(True)
stats = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=28)
stats.set_hexpand(True)
self._stat_ok = _make_stat("Succeeded", "0")
self._stat_ok.value.add_css_class("success")
stats.append(self._stat_ok.root)
stats.append(_divider())
self._stat_fail = _make_stat("Failed", "0")
stats.append(self._stat_fail.root)
stats.append(_divider())
self._stat_out = _make_stat("Output size", "", small=True)
stats.append(self._stat_out.root)
box.append(stats)
actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
reveal = Gtk.Button()
reveal.add_css_class("reveal-btn")
reveal.set_child(_icon_label("folder-symbolic", "Reveal in Files"))
reveal.connect("clicked", lambda _b: self.emit("reveal-requested"))
actions.append(reveal)
again = Gtk.Button()
again.add_css_class("encode-cta")
again.set_child(_icon_label_light("media-playback-start-symbolic", "Encode again"))
again.connect("clicked", lambda _b: self.emit("encode-requested"))
actions.append(again)
box.append(actions)
return box
# ---------- render ----------
def _render(self) -> None:
self._ready_box.set_visible(self._state == "ready")
self._encoding_box.set_visible(self._state == "encoding")
self._complete_box.set_visible(self._state == "complete")
if self._state == "ready":
self._render_ready()
elif self._state == "encoding":
self._render_encoding()
else:
self._render_complete()
def _render_ready(self) -> None:
files = self._files
total_in = sum(f.size for f in files)
total_out = sum(f.est_out for f in files)
total_dur = sum((f.meta.duration or 0) for f in files)
est_sec = estimate_encode_seconds(total_dur, self._profile_id)
self._stat_files.value.set_text(str(len(files)))
self._stat_dur.value.set_text(format_duration(total_dur) if total_dur else "")
self._stat_out_box.in_lbl.set_text(format_bytes(total_in) if total_in else "")
self._stat_out_box.out_lbl.set_text(format_bytes(total_out) if total_out else "")
self._stat_eta.value.set_text(f"~{format_duration(est_sec)}" if est_sec else "")
can_encode = len(files) > 0
self._encode_btn.set_sensitive(can_encode)
child = self._encode_btn.get_child()
# Replace the label text based on file count.
n = len(files)
text = f"Encode {n} file{'s' if n != 1 else ''}" if n else "Encode"
_set_icon_label_text(child, text)
def _render_encoding(self) -> None:
files = self._files
if not files:
self._enc_title.set_text("")
self._enc_pct.set_text("0%")
self._enc_progress.set_fraction(0)
return
idx = max(0, min(self._current_idx, len(files) - 1))
f = files[idx]
self._enc_title.set_markup(
f'<span foreground="#7982a9">[{idx + 1}/{len(files)}]</span> {GLib_markup_escape(f.name)}'
)
pct = int(round(self._overall * 100))
self._enc_pct.set_text(f"{pct}%")
self._enc_progress.set_fraction(self._overall)
done = sum(1 for x in files if x.status == "done")
failed = sum(1 for x in files if x.status == "failed")
queued = len(files) - done - failed - (1 if f.status == "encoding" else 0)
queued = max(0, queued)
parts = [f'<span class="ok" foreground="#9ece6a">● {done} done</span>']
if failed:
parts.append(f'<span class="fail" foreground="#e06c75">● {failed} failed</span>')
parts.append(f'{queued} queued')
self._enc_status_left.set_markup(" ".join(parts))
# ETA estimate. If ffmpeg has reported a real speed, refine the
# remaining-time estimate from actual throughput rather than the
# profile-specific heuristic — much closer to real once the encode
# is past its first second or so.
total_dur = sum((x.meta.duration or 0) for x in files)
if self._speed and self._speed > 0:
remaining_src_sec = total_dur * (1 - self._overall)
remaining = remaining_src_sec / self._speed
else:
est_total = estimate_encode_seconds(total_dur, self._profile_id)
remaining = max(0.0, est_total * (1 - self._overall))
eta_text = f"~{format_duration(remaining)} remaining"
if self._speed:
eta_text += f" · {self._speed:.2f}×"
self._enc_eta.set_text(eta_text)
def _render_complete(self) -> None:
files = self._files
ok = sum(1 for f in files if f.status == "done")
fail = sum(1 for f in files if f.status == "failed")
total_out = sum(f.est_out for f in files if f.status == "done")
self._stat_ok.value.set_text(str(ok))
self._stat_fail.value.set_text(str(fail))
if fail > 0:
self._stat_fail.value.add_css_class("danger")
else:
self._stat_fail.value.remove_css_class("danger")
self._stat_out.value.set_text(format_bytes(total_out) if total_out else "")
# ---------- helpers ----------
class _Stat:
__slots__ = ("root", "value")
def __init__(self, root: Gtk.Widget, value: Gtk.Label) -> None:
self.root = root
self.value = value
class _IOStat:
__slots__ = ("root", "in_lbl", "arrow_lbl", "out_lbl")
def __init__(self, root: Gtk.Widget, in_lbl: Gtk.Label, arrow_lbl: Gtk.Label, out_lbl: Gtk.Label) -> None:
self.root = root
self.in_lbl = in_lbl
self.arrow_lbl = arrow_lbl
self.out_lbl = out_lbl
def _make_stat(label: str, value: str, *, small: bool = False, with_clock: bool = False) -> _Stat:
col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
lbl = Gtk.Label(label=label.upper(), xalign=0)
lbl.add_css_class("stat-label")
col.append(lbl)
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
if with_clock:
icon = Gtk.Image.new_from_icon_name("preferences-system-time-symbolic")
icon.set_pixel_size(11)
row.append(icon)
val = Gtk.Label(label=value, xalign=0)
val.add_css_class("stat-value-sm" if small else "stat-value")
row.append(val)
col.append(row)
return _Stat(col, val)
def _make_io_stat(label: str) -> _IOStat:
col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
lbl = Gtk.Label(label=label.upper(), xalign=0)
lbl.add_css_class("stat-label")
col.append(lbl)
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
in_lbl = Gtk.Label(label="", xalign=0)
in_lbl.add_css_class("stat-value-sm")
in_lbl.add_css_class("stat-in")
row.append(in_lbl)
arrow = Gtk.Label(label="", xalign=0)
arrow.add_css_class("stat-value-sm")
arrow.add_css_class("stat-arrow")
row.append(arrow)
out_lbl = Gtk.Label(label="", xalign=0)
out_lbl.add_css_class("stat-value-sm")
out_lbl.add_css_class("stat-out")
row.append(out_lbl)
col.append(row)
return _IOStat(col, in_lbl, arrow, out_lbl)
def _divider() -> Gtk.Widget:
div = Gtk.Box()
div.add_css_class("footer-divider")
return div
def _icon_label(icon_name: str, text: str) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
img = Gtk.Image.new_from_icon_name(icon_name)
img.set_pixel_size(14)
box.append(img)
lbl = Gtk.Label(label=text)
box.append(lbl)
box._nocoder_label = lbl # type: ignore[attr-defined]
return box
def _icon_label_light(icon_name: str, text: str) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
img = Gtk.Image.new_from_icon_name(icon_name)
img.set_pixel_size(14)
box.append(img)
lbl = Gtk.Label(label=text)
box.append(lbl)
box._nocoder_label = lbl # type: ignore[attr-defined]
return box
def _set_icon_label_text(widget: Optional[Gtk.Widget], text: str) -> None:
if widget is None:
return
lbl = getattr(widget, "_nocoder_label", None)
if lbl is not None:
lbl.set_label(text)
def GLib_markup_escape(s: str) -> str:
# Small helper so we don't have to import GLib just for this.
return (
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)

108
nocoder/hwaccel.py Normal file
View file

@ -0,0 +1,108 @@
"""GPU hardware-accelerated decode selection.
We only accelerate decoding of the input file ProRes encoding itself always
runs on CPU (no vendor ships a GPU ProRes encoder). Offloading decode from a
handful of the user's cores frees them up for the actual ProRes encode, which
is the typical bottleneck on camera-native (H.264 / HEVC / AV1) sources.
The selected hwaccel is cached at ``$XDG_CONFIG_HOME/nocoder/config.json`` so
we probe once per machine (install-time) rather than on every launch. If the
config is missing, the first encode will lazily re-probe and cache.
"""
from __future__ import annotations
import json
import os
import subprocess
import threading
from pathlib import Path
from typing import Optional
CONFIG_PATH = (
Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
/ "nocoder"
/ "config.json"
)
# Ordered by vendor preference: NVIDIA > Intel > AMD/generic. ffmpeg silently
# falls back to CPU decode when the source codec can't be GPU-decoded (MJPEG,
# ProRes input, etc.) so picking a hwaccel even on ProRes-only workflows is
# harmless.
_CANDIDATES = ("cuda", "qsv", "vaapi")
_cache: tuple[bool, Optional[str]] = (False, None)
# Guards check-then-set on `_cache` when two worker threads kick off encodes
# before the first-time probe has completed. Probing ffmpeg twice is harmless
# but wasteful and clutters the config-write path.
_cache_lock = threading.Lock()
def get_hwaccel() -> Optional[str]:
"""Return the selected hwaccel name, or None for CPU-only decode.
Reads from the on-disk config if present; otherwise probes the system,
writes the result, and returns it. Results are memoised for the process.
"""
global _cache
with _cache_lock:
if _cache[0]:
return _cache[1]
choice = _read_configured_hwaccel()
if choice is _Sentinel.MISSING:
choice = probe_best_hwaccel()
save_hwaccel(choice)
_cache = (True, choice) # type: ignore[assignment]
return choice # type: ignore[return-value]
def probe_best_hwaccel() -> Optional[str]:
"""Return the first hwaccel that actually initialises on this machine."""
for candidate in _CANDIDATES:
if _hwaccel_works(candidate):
return candidate
return None
def save_hwaccel(hw: Optional[str]) -> None:
"""Persist the selected hwaccel. ``None`` means CPU decode."""
try:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(json.dumps({"hwaccel": hw or "none"}, indent=2) + "\n")
except OSError:
pass
class _Sentinel:
MISSING = object()
def _read_configured_hwaccel():
"""Return the stored hwaccel, None (CPU), or MISSING (no config yet)."""
if not CONFIG_PATH.exists():
return _Sentinel.MISSING
try:
data = json.loads(CONFIG_PATH.read_text())
except (OSError, json.JSONDecodeError):
return _Sentinel.MISSING
hw = data.get("hwaccel")
if hw in (None, "", "none"):
return None
return hw if hw in _CANDIDATES else None
def _hwaccel_works(hwaccel: str) -> bool:
"""Run a throwaway 1-frame pipeline to test that `hwaccel` initialises."""
try:
proc = subprocess.run(
[
"ffmpeg", "-hide_banner", "-loglevel", "error",
"-init_hw_device", hwaccel,
"-f", "lavfi", "-i", "nullsrc=s=32x32",
"-frames:v", "1",
"-f", "null", "-",
],
capture_output=True, text=True, timeout=5, check=False,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
return proc.returncode == 0

637
nocoder/queue_pane.py Normal file
View file

@ -0,0 +1,637 @@
"""Queue pane: drop zone (empty) or file list (populated), with action bar.
Emits:
add-files-requested ()
add-folder-requested ()
clear-requested ()
files-dropped (paths: GLib.Variant[array of str])
selection-changed (file_id: str)
remove-requested (file_id: str)
"""
from __future__ import annotations
import os
import urllib.parse
import urllib.request
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
_ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
_DROP_LOGO_PATH = _ASSETS_DIR / "logo.png"
_DROP_LOGO_SIZE = 88
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
gi.require_version("GdkPixbuf", "2.0")
gi.require_version("GObject", "2.0")
from gi.repository import GdkPixbuf, GLib, GObject, Gdk, Gio, Gtk
from .data import VIDEO_EXTENSIONS, format_bytes, format_duration
from .encoder import Metadata
@dataclass
class FileEntry:
path: str
size: int
id: str = field(default_factory=lambda: uuid.uuid4().hex)
meta: Metadata = field(default_factory=Metadata)
est_out: float = 0.0
status: str = "queued" # queued | encoding | done | failed
progress: float = 0.0 # 0..1
error: Optional[str] = None
@property
def name(self) -> str:
return os.path.basename(self.path)
class QueuePane(Gtk.Box):
__gtype_name__ = "NoCoderQueuePane"
__gsignals__ = {
"add-files-requested": (GObject.SignalFlags.RUN_LAST, None, ()),
"add-folder-requested": (GObject.SignalFlags.RUN_LAST, None, ()),
"clear-requested": (GObject.SignalFlags.RUN_LAST, None, ()),
"files-dropped": (GObject.SignalFlags.RUN_LAST, None, (object,)),
"selection-changed": (GObject.SignalFlags.RUN_LAST, None, (str,)),
"remove-requested": (GObject.SignalFlags.RUN_LAST, None, (str,)),
}
def __init__(self) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.add_css_class("queue-pane")
self.set_hexpand(True)
self.set_vexpand(True)
self._files: list[FileEntry] = []
self._selected_id: Optional[str] = None
self._encoding_locked: bool = False
self._row_by_id: dict[str, Gtk.ListBoxRow] = {}
self._body_child: Optional[Gtk.Widget] = None
self._search_query: str = ""
self._build_header()
self._build_action_bar()
self._build_body_stack()
self._install_drop_target(self)
# ---------- header ----------
def _build_header(self) -> None:
header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
header.add_css_class("pane-header")
header.set_hexpand(True)
label = Gtk.Label(label="QUEUE", xalign=0)
label.add_css_class("pane-label")
label.set_hexpand(True)
header.append(label)
right = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
self._count_chip = Gtk.Label(label="0")
self._count_chip.add_css_class("count-chip")
right.append(self._count_chip)
self._size_chip = Gtk.Label(label="")
self._size_chip.add_css_class("count-chip")
self._size_chip.add_css_class("secondary")
self._size_chip.set_visible(False)
right.append(self._size_chip)
header.append(right)
self.append(header)
def _build_action_bar(self) -> None:
self._action_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
self._action_bar.add_css_class("action-bar")
self._action_bar.set_visible(False)
add_files = Gtk.Button()
add_files.add_css_class("muted-btn")
add_files.set_child(_icon_label("list-add-symbolic", "Add files"))
add_files.connect("clicked", lambda _b: self.emit("add-files-requested"))
self._action_bar.append(add_files)
add_folder = Gtk.Button()
add_folder.add_css_class("muted-btn")
add_folder.set_child(_icon_label("folder-symbolic", "Add folder"))
add_folder.connect("clicked", lambda _b: self.emit("add-folder-requested"))
self._action_bar.append(add_folder)
spacer = Gtk.Box()
spacer.set_hexpand(True)
self._action_bar.append(spacer)
clear = Gtk.Button()
clear.add_css_class("muted-btn")
clear.add_css_class("clear-btn")
clear.set_child(_icon_label("user-trash-symbolic", "Clear"))
clear.connect("clicked", lambda _b: self.emit("clear-requested"))
self._clear_btn = clear
self._action_bar.append(clear)
self.append(self._action_bar)
def _build_body_stack(self) -> None:
# A body container we swap between drop zone and scrolled list.
self._body_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self._body_box.set_vexpand(True)
self._body_box.set_hexpand(True)
self.append(self._body_box)
self._show_drop_zone()
# ---------- drop zone ----------
def _show_drop_zone(self) -> None:
self._clear_body()
wrapper = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
wrapper.set_vexpand(True)
wrapper.set_hexpand(True)
drop = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
drop.add_css_class("drop-zone")
drop.set_vexpand(True)
drop.set_hexpand(True)
drop.set_halign(Gtk.Align.FILL)
drop.set_valign(Gtk.Align.FILL)
# Spacer pushes content to center vertically.
top_spacer = Gtk.Box()
top_spacer.set_vexpand(True)
drop.append(top_spacer)
drop.append(_build_drop_logo())
heading = Gtk.Label(label="Drop videos here")
heading.add_css_class("drop-heading")
heading.set_halign(Gtk.Align.CENTER)
drop.append(heading)
sub = Gtk.Label()
sub.add_css_class("drop-sub")
sub.set_halign(Gtk.Align.CENTER)
sub.set_justify(Gtk.Justification.CENTER)
sub.set_wrap(True)
sub.set_max_width_chars(44)
sub.set_markup(
'Or <span foreground="#ff8c42" weight="500">browse files</span> to add them to the queue. '
"Whole folders work too — non-video files are ignored."
)
drop.append(sub)
buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8, halign=Gtk.Align.CENTER)
primary = Gtk.Button()
primary.add_css_class("muted-btn")
primary.add_css_class("accent-outline")
primary.set_child(_icon_label("list-add-symbolic", "Add files"))
primary.connect("clicked", lambda _b: self.emit("add-files-requested"))
buttons.append(primary)
secondary = Gtk.Button()
secondary.add_css_class("muted-btn")
secondary.set_child(_icon_label("folder-symbolic", "Add folder"))
secondary.connect("clicked", lambda _b: self.emit("add-folder-requested"))
buttons.append(secondary)
drop.append(buttons)
hint = Gtk.Label()
hint.add_css_class("drop-hint")
hint.set_halign(Gtk.Align.CENTER)
hint.set_markup(
'Accepts <span face="JetBrains Mono">.mov .mp4 .mkv .avi .mxf .mts</span>'
' and more. Folders and camera cards are scanned recursively.'
)
drop.append(hint)
bottom_spacer = Gtk.Box()
bottom_spacer.set_vexpand(True)
drop.append(bottom_spacer)
wrapper.append(drop)
self._body_box.append(wrapper)
self._body_child = wrapper
self._drop_widget = drop
# ---------- list view ----------
def _show_list(self) -> None:
self._clear_body()
scroller = Gtk.ScrolledWindow()
scroller.set_vexpand(True)
scroller.set_hexpand(True)
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
listbox = Gtk.ListBox()
listbox.add_css_class("queue-list")
listbox.set_selection_mode(Gtk.SelectionMode.SINGLE)
listbox.connect("row-activated", self._on_row_activated)
listbox.connect("row-selected", self._on_row_selected)
placeholder = Gtk.Label(label="No files match your search.")
placeholder.add_css_class("queue-empty-matches")
placeholder.set_halign(Gtk.Align.CENTER)
placeholder.set_valign(Gtk.Align.CENTER)
listbox.set_placeholder(placeholder)
self._listbox = listbox
scroller.set_child(listbox)
self._body_box.append(scroller)
self._body_child = scroller
def _clear_body(self) -> None:
if self._body_child is not None:
self._body_box.remove(self._body_child)
self._body_child = None
self._row_by_id.clear()
self._drop_widget = None
# ---------- drop target ----------
def _install_drop_target(self, widget: Gtk.Widget) -> None:
# Accept several value types — different file managers (Nautilus,
# Thunar, Files under XWayland, etc.) deliver drops as Gdk.FileList,
# a single Gio.File, or a text/uri-list string.
actions = Gdk.DragAction.COPY | Gdk.DragAction.MOVE | Gdk.DragAction.LINK
target = Gtk.DropTarget.new(Gdk.FileList, actions)
target.set_gtypes([Gdk.FileList, Gio.File, GObject.TYPE_STRING])
target.set_preload(True)
target.connect("drop", self._on_drop)
target.connect("enter", self._on_drop_enter)
target.connect("motion", self._on_drop_motion)
target.connect("leave", self._on_drop_leave)
widget.add_controller(target)
def _on_drop(self, _target: Gtk.DropTarget, value, _x: float, _y: float) -> bool:
paths = _paths_from_drop_value(value)
self._set_drop_hover(False)
if not paths:
return False
self.emit("files-dropped", paths)
return True
def _on_drop_enter(self, _target: Gtk.DropTarget, _x: float, _y: float) -> Gdk.DragAction:
self._set_drop_hover(True)
return Gdk.DragAction.COPY
def _on_drop_motion(self, _target: Gtk.DropTarget, _x: float, _y: float) -> Gdk.DragAction:
return Gdk.DragAction.COPY
def _on_drop_leave(self, _target: Gtk.DropTarget) -> None:
self._set_drop_hover(False)
def _set_drop_hover(self, on: bool) -> None:
w = getattr(self, "_drop_widget", None)
if w is None:
return
if on:
w.add_css_class("drop-hover")
else:
w.remove_css_class("drop-hover")
# ---------- external API ----------
def set_encoding(self, encoding: bool) -> None:
self._encoding_locked = encoding
self._clear_btn.set_sensitive(not encoding)
for row in self._row_by_id.values():
btn = getattr(row, "_nocoder_widgets", {}).get("remove")
if btn is not None:
btn.set_sensitive(not encoding)
def set_files(self, files: list[FileEntry]) -> None:
self._files = list(files)
self._refresh_header()
self._action_bar.set_visible(bool(self._files))
if not self._files:
self._show_drop_zone()
return
self._show_list()
self._populate_list()
self._apply_selection()
def set_search_query(self, query: str) -> None:
new_q = (query or "").strip().lower()
if new_q == self._search_query:
return
self._search_query = new_q
if not self._files:
return
if getattr(self, "_listbox", None) is None:
return
self._populate_list()
self._apply_selection()
def _populate_list(self) -> None:
# Clear existing rows.
self._row_by_id.clear()
child = self._listbox.get_first_child()
while child is not None:
nxt = child.get_next_sibling()
self._listbox.remove(child)
child = nxt
# Append rows that match the current search query (empty = all).
q = self._search_query
for entry in self._files:
if q and q not in entry.name.lower():
continue
row = self._build_row(entry)
self._listbox.append(row)
self._row_by_id[entry.id] = row
def update_file(self, entry: FileEntry) -> None:
"""Called when a single file's metadata/progress/status changed. Updates row in place."""
for i, f in enumerate(self._files):
if f.id == entry.id:
self._files[i] = entry
break
else:
return
row = self._row_by_id.get(entry.id)
if row is None:
return
old_widgets = getattr(row, "_nocoder_widgets", {})
_populate_row(row, entry, old_widgets)
self._refresh_header()
def set_selected(self, file_id: Optional[str]) -> None:
self._selected_id = file_id
self._apply_selection()
# ---------- internals ----------
def _refresh_header(self) -> None:
self._count_chip.set_text(str(len(self._files)))
if self._files:
total_in = sum(f.size for f in self._files)
total_out = sum(f.est_out for f in self._files)
self._size_chip.set_text(f"{format_bytes(total_in)}{format_bytes(total_out)}")
self._size_chip.set_visible(True)
else:
self._size_chip.set_visible(False)
def _apply_selection(self) -> None:
for fid, row in self._row_by_id.items():
inner = getattr(row, "_nocoder_widgets", {}).get("container")
if inner is None:
continue
if fid == self._selected_id:
inner.add_css_class("selected")
else:
inner.remove_css_class("selected")
def _on_row_activated(self, _lb, row: Gtk.ListBoxRow) -> None:
fid = getattr(row, "_nocoder_id", None)
if fid:
self._selected_id = fid
self._apply_selection()
self.emit("selection-changed", fid)
def _on_row_selected(self, _lb, row: Optional[Gtk.ListBoxRow]) -> None:
if row is None:
return
fid = getattr(row, "_nocoder_id", None)
if fid:
self._selected_id = fid
self._apply_selection()
self.emit("selection-changed", fid)
def _build_row(self, entry: FileEntry) -> Gtk.ListBoxRow:
row = Gtk.ListBoxRow()
row.set_activatable(True)
row.set_selectable(True)
row._nocoder_id = entry.id
widgets: dict[str, Gtk.Widget] = {}
container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
container.add_css_class("file-row")
widgets["container"] = container
# Thumbnail
thumb = Gtk.Image.new_from_icon_name("video-x-generic-symbolic")
thumb.add_css_class("file-thumb")
thumb.set_pixel_size(18)
container.append(thumb)
# Center: name + meta + progress
center = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
center.set_hexpand(True)
center.set_valign(Gtk.Align.CENTER)
name = Gtk.Label(xalign=0)
name.add_css_class("filename")
name.set_ellipsize(3) # PANGO_ELLIPSIZE_END
name.set_hexpand(True)
widgets["name"] = name
center.append(name)
meta_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
meta_box.add_css_class("file-meta")
widgets["meta_box"] = meta_box
center.append(meta_box)
progress = Gtk.ProgressBar()
progress.add_css_class("file-progress")
progress.set_visible(False)
widgets["progress"] = progress
center.append(progress)
container.append(center)
# Right: input size + est out
right = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
right.set_halign(Gtk.Align.END)
right.set_valign(Gtk.Align.CENTER)
size_lbl = Gtk.Label(xalign=1.0)
size_lbl.add_css_class("file-size")
widgets["size"] = size_lbl
right.append(size_lbl)
est_lbl = Gtk.Label(xalign=1.0)
est_lbl.add_css_class("file-estout")
widgets["est"] = est_lbl
right.append(est_lbl)
container.append(right)
# Status dot
dot = Gtk.Box()
dot.add_css_class("status-dot")
dot.set_halign(Gtk.Align.CENTER)
dot.set_valign(Gtk.Align.CENTER)
widgets["dot"] = dot
container.append(dot)
# Remove button (hover-revealed via CSS).
remove = Gtk.Button()
remove.add_css_class("file-row-remove")
remove.set_child(Gtk.Image.new_from_icon_name("window-close-symbolic"))
remove.set_tooltip_text("Remove from queue")
remove.set_valign(Gtk.Align.CENTER)
remove.set_can_focus(False)
remove.set_sensitive(not self._encoding_locked)
remove.connect("clicked", lambda _b, fid=entry.id: self.emit("remove-requested", fid))
widgets["remove"] = remove
container.append(remove)
row.set_child(container)
row._nocoder_widgets = widgets
_populate_row(row, entry, widgets)
return row
# ---------- module-level helpers ----------
def _build_drop_logo() -> Gtk.Widget:
"""The NO-CODER brand mark shown above the drop-zone copy.
Pre-scales the source PNG to 2× the display size so HiDPI stays crisp,
then wraps it in a Gtk.Image so the rendered size is exactly what we ask
for (Gtk.Picture's natural size is the source's 800×800 and only acts as
a minimum, so size_request can't shrink it).
"""
if _DROP_LOGO_PATH.exists():
try:
hidpi = _DROP_LOGO_SIZE * 2
pb = GdkPixbuf.Pixbuf.new_from_file_at_scale(
str(_DROP_LOGO_PATH), hidpi, hidpi, True
)
img = Gtk.Image.new_from_pixbuf(pb)
img.set_pixel_size(_DROP_LOGO_SIZE)
img.set_halign(Gtk.Align.CENTER)
img.add_css_class("drop-logo")
return img
except GLib.Error:
pass
icon = Gtk.Image.new_from_icon_name("video-x-generic-symbolic")
icon.set_pixel_size(40)
icon.add_css_class("drop-icon")
icon.set_halign(Gtk.Align.CENTER)
return icon
def _paths_from_drop_value(value) -> list[str]:
"""Extract local filesystem paths from whatever a Gtk.DropTarget delivered.
Supports Gdk.FileList (multi-file drops), a single Gio.File, and a
text/uri-list-style string (lines of file:// URIs or plain paths).
"""
paths: list[str] = []
if value is None:
return paths
# Gdk.FileList
if hasattr(value, "get_files"):
try:
for f in value.get_files():
p = f.get_path() if hasattr(f, "get_path") else None
if p:
paths.append(p)
return paths
except Exception:
pass
# Single Gio.File
if hasattr(value, "get_path"):
p = value.get_path()
if p:
paths.append(p)
return paths
# text/uri-list or raw path string
if isinstance(value, str):
for line in value.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("file:"):
# Let the stdlib handle the host part + %-decoding properly.
# `file://hostname/path` → `/path`; `file:///path` → `/path`.
parsed = urllib.parse.urlparse(line)
line = urllib.request.url2pathname(parsed.path)
paths.append(line)
return paths
def _icon_label(icon_name: str, text: str) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
img = Gtk.Image.new_from_icon_name(icon_name)
img.set_pixel_size(14)
box.append(img)
box.append(Gtk.Label(label=text))
return box
def _populate_row(row: Gtk.ListBoxRow, entry: FileEntry, widgets: dict) -> None:
widgets["name"].set_text(entry.name)
# Meta
meta_box: Gtk.Box = widgets["meta_box"]
_clear_children(meta_box)
parts: list[tuple[str, Optional[str]]] = []
if entry.meta.resolution != "":
parts.append((entry.meta.resolution, None))
if entry.meta.codec:
parts.append((entry.meta.codec, None))
if entry.meta.fps:
parts.append((f"{entry.meta.fps:g}fps", None))
if entry.meta.duration:
parts.append((format_duration(entry.meta.duration), None))
if entry.meta.alpha:
parts.append(("α", "alpha-mark"))
if not parts:
lbl = Gtk.Label(label="probing…", xalign=0)
meta_box.append(lbl)
else:
for i, (text, cls) in enumerate(parts):
if i > 0:
sep = Gtk.Label(label="·")
sep.add_css_class("sep")
meta_box.append(sep)
lbl = Gtk.Label(label=text, xalign=0)
if cls:
lbl.add_css_class(cls)
meta_box.append(lbl)
# Sizes
widgets["size"].set_text(format_bytes(entry.size) if entry.size else "")
widgets["est"].set_text(f"{format_bytes(entry.est_out)}" if entry.est_out else "→ —")
# Progress
pb: Gtk.ProgressBar = widgets["progress"]
if entry.status == "encoding":
pb.set_fraction(min(1.0, max(0.0, entry.progress)))
pb.set_visible(True)
else:
pb.set_visible(False)
# Status dot
dot: Gtk.Box = widgets["dot"]
for cls in ("queued", "encoding", "done", "failed"):
dot.remove_css_class(cls)
dot.add_css_class(entry.status)
_clear_children(dot)
inner = _status_icon_for(entry.status)
if inner is not None:
dot.append(inner)
def _status_icon_for(status: str) -> Optional[Gtk.Widget]:
if status == "done":
img = Gtk.Image.new_from_icon_name("emblem-ok-symbolic")
img.set_pixel_size(10)
return img
if status == "failed":
img = Gtk.Image.new_from_icon_name("window-close-symbolic")
img.set_pixel_size(10)
return img
if status == "encoding":
spinner = Gtk.Spinner()
spinner.set_size_request(10, 10)
spinner.set_spinning(True)
return spinner
return None
def _clear_children(box: Gtk.Box) -> None:
child = box.get_first_child()
while child is not None:
nxt = child.get_next_sibling()
box.remove(child)
child = nxt

588
nocoder/settings_pane.py Normal file
View file

@ -0,0 +1,588 @@
"""Settings pane: profile picker, alpha toggle, naming, output folder, ffmpeg preview.
Emits:
settings-changed ()
choose-folder-requested ()
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Optional
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("GObject", "2.0")
from gi.repository import GObject, Gtk, Pango
from .data import PROFILES, PROFILES_BY_ID
from .encoder import format_preview_command
def _resolve_theme_hex(widget: Gtk.Widget, name: str, fallback: str) -> str:
"""Look up a libadwaita @named-color from the widget's style context.
Returns the colour as a `#rrggbb` string. Falls back to `fallback` when
the name isn't registered (e.g. before the CSS providers are wired up on
a pre-realised widget, or on a system where Omarchy's theme palette isn't
loaded).
"""
try:
ok, rgba = widget.get_style_context().lookup_color(name)
except Exception:
return fallback
if not ok:
return fallback
r = int(round(rgba.red * 255))
g = int(round(rgba.green * 255))
b = int(round(rgba.blue * 255))
return f"#{r:02x}{g:02x}{b:02x}"
class Settings:
__slots__ = ("profile", "alpha", "naming", "out_dir", "audio_bits", "auto_reveal")
def __init__(
self,
profile: str = "hq",
alpha: bool = False,
naming: str = "suffix",
out_dir: str = "",
audio_bits: int = 16,
auto_reveal: bool = False,
) -> None:
self.profile = profile
self.alpha = alpha
self.naming = naming
self.out_dir = out_dir or str(Path.home() / "Footage" / "prores")
# 16 = pcm_s16le (editorial default, matches prowrap-yad.sh)
# 24 = pcm_s24le (preserves pro-camera bit depth; ~50% bigger audio)
self.audio_bits = audio_bits
# If True, _finish_encoding opens the output folder via Files when the
# batch completes. Convenient for one-shot transcodes; off by default
# so the app doesn't surprise users mid-workflow.
self.auto_reveal = auto_reveal
def snapshot(self) -> "Settings":
return Settings(
self.profile, self.alpha, self.naming, self.out_dir,
self.audio_bits, self.auto_reveal,
)
class SettingsPane(Gtk.Box):
__gtype_name__ = "NoCoderSettingsPane"
__gsignals__ = {
"settings-changed": (GObject.SignalFlags.RUN_LAST, None, ()),
"choose-folder-requested": (GObject.SignalFlags.RUN_LAST, None, ()),
}
def __init__(self, settings: Settings, encoder_kind: str) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.add_css_class("settings-pane")
self.set_size_request(380, -1)
self.set_hexpand(False)
self._settings = settings
self._encoder_kind = encoder_kind
self._encoding_locked = False
self._first_file_name: Optional[str] = None
self._profile_buttons: dict[str, Gtk.ToggleButton] = {}
self._profile_rows: dict[str, Gtk.Widget] = {}
self._profile_radios: dict[str, Gtk.Widget] = {}
self._profile_handlers: dict[str, int] = {}
self._alpha_handler_id: int = 0
self._naming_handler_id: int = 0
self._cmd_visible = True
self._build_header()
self._build_scroll_body()
# ---------- public ----------
@property
def settings(self) -> Settings:
return self._settings
def set_encoding(self, encoding: bool) -> None:
self._encoding_locked = encoding
# Lock interactive sub-widgets
for btn in self._profile_buttons.values():
btn.set_sensitive(not encoding)
self._alpha_switch.set_sensitive(not encoding and self._alpha_available())
if hasattr(self, "_audio_bits_switch"):
self._audio_bits_switch.set_sensitive(not encoding)
if hasattr(self, "_auto_reveal_switch"):
self._auto_reveal_switch.set_sensitive(not encoding)
self._naming_dropdown.set_sensitive(not encoding)
self._browse_btn.set_sensitive(not encoding)
def set_first_file_name(self, name: Optional[str]) -> None:
self._first_file_name = name
self._update_cmd_preview()
def refresh(self) -> None:
"""Re-sync all widgets to the current Settings snapshot (accent handled via CSS)."""
for pid, btn in self._profile_buttons.items():
selected = (pid == self._settings.profile)
hid = self._profile_handlers.get(pid, 0)
if hid:
btn.handler_block(hid)
try:
btn.set_active(selected)
finally:
if hid:
btn.handler_unblock(hid)
self._apply_profile_visual(pid, selected)
self._refresh_alpha_row()
if self._naming_handler_id:
self._naming_dropdown.handler_block(self._naming_handler_id)
try:
self._naming_dropdown.set_selected(0 if self._settings.naming == "keep" else 1)
finally:
if self._naming_handler_id:
self._naming_dropdown.handler_unblock(self._naming_handler_id)
self._folder_path.set_text(self._settings.out_dir)
self._update_cmd_preview()
# ---------- header ----------
def _build_header(self) -> None:
header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
header.add_css_class("pane-header")
header.set_hexpand(True)
label = Gtk.Label(label="ENCODE SETTINGS", xalign=0)
label.add_css_class("pane-label")
label.set_hexpand(True)
header.append(label)
chip_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
chip_box.add_css_class("encoder-chip")
icon = Gtk.Image.new_from_icon_name("preferences-desktop-apps-symbolic")
icon.set_pixel_size(12)
chip_box.append(icon)
label_text = self._encoder_kind if self._encoder_kind in ("ks", "plain") else "none"
chip_name = "prores_ks" if label_text == "ks" else ("prores" if label_text == "plain" else "no encoder")
chip_box.append(Gtk.Label(label=chip_name))
header.append(chip_box)
self.append(header)
# ---------- body ----------
def _build_scroll_body(self) -> None:
scroller = Gtk.ScrolledWindow()
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scroller.set_vexpand(True)
body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=22)
body.set_margin_top(14)
body.set_margin_bottom(120)
body.set_margin_start(16)
body.set_margin_end(16)
body.append(self._build_profile_section())
body.append(self._build_alpha_section())
body.append(self._build_audio_bits_section())
body.append(self._build_auto_reveal_section())
body.append(self._build_naming_section())
body.append(self._build_folder_section())
body.append(self._build_cmd_section())
scroller.set_child(body)
self.append(scroller)
# ---------- profile picker ----------
def _build_profile_section(self) -> Gtk.Widget:
section = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
label = Gtk.Label(label="ProRes profile", xalign=0)
label.add_css_class("section-label")
section.append(label)
sub = Gtk.Label(xalign=0)
sub.add_css_class("section-sublabel")
sub.set_wrap(True)
sub.set_max_width_chars(50)
sub.set_label("Higher bitrates preserve more detail. HQ is the editorial default.")
section.append(sub)
section.append(Gtk.Box(height_request=4))
group_root: Optional[Gtk.ToggleButton] = None
for profile in PROFILES:
btn = Gtk.ToggleButton()
btn.add_css_class("profile-row")
btn.set_has_frame(False)
btn.set_active(profile.id == self._settings.profile)
handler_id = btn.connect("toggled", self._on_profile_toggled, profile.id)
self._profile_handlers[profile.id] = handler_id
if group_root is None:
group_root = btn
else:
btn.set_group(group_root)
inner = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
# Radio outer
radio = Gtk.Box()
radio.add_css_class("profile-radio-outer")
radio.set_valign(Gtk.Align.CENTER)
inner.append(radio)
self._profile_radios[profile.id] = radio
# Name + desc
col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
col.set_hexpand(True)
name_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
name = Gtk.Label(label=profile.name, xalign=0)
name.add_css_class("profile-name")
name_box.append(name)
if profile.alpha:
alpha_tag = Gtk.Label(label="+ alpha", xalign=0)
alpha_tag.add_css_class("alpha-tag")
name_box.append(alpha_tag)
col.append(name_box)
desc = Gtk.Label(label=profile.desc, xalign=0)
desc.add_css_class("profile-desc")
desc.set_ellipsize(Pango.EllipsizeMode.END)
col.append(desc)
inner.append(col)
# Badge
badge = Gtk.Label(label=f"PID {profile.pid}")
badge.add_css_class("profile-badge")
badge.set_valign(Gtk.Align.CENTER)
inner.append(badge)
btn.set_child(inner)
self._profile_buttons[profile.id] = btn
self._profile_rows[profile.id] = btn
self._apply_profile_visual(profile.id, profile.id == self._settings.profile)
section.append(btn)
return section
def _apply_profile_visual(self, profile_id: str, selected: bool) -> None:
btn = self._profile_buttons.get(profile_id)
radio = self._profile_radios.get(profile_id)
if btn is None or radio is None:
return
if selected:
btn.add_css_class("selected")
radio.add_css_class("selected")
else:
btn.remove_css_class("selected")
radio.remove_css_class("selected")
def _on_profile_toggled(self, btn: Gtk.ToggleButton, profile_id: str) -> None:
if not btn.get_active():
return
# Ensure only one row carries the .selected class.
for pid in self._profile_buttons:
self._apply_profile_visual(pid, pid == profile_id)
if self._settings.profile != profile_id:
self._settings.profile = profile_id
# Force-off alpha if the new profile can't do it.
if not PROFILES_BY_ID[profile_id].alpha and self._settings.alpha:
self._settings.alpha = False
self._set_alpha_switch_silent(False)
self._refresh_alpha_row()
self._update_cmd_preview()
self.emit("settings-changed")
# ---------- alpha toggle ----------
def _build_alpha_section(self) -> Gtk.Widget:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
row.add_css_class("toggle-row")
col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
col.set_hexpand(True)
title = Gtk.Label(label="Include alpha channel", xalign=0)
title.add_css_class("toggle-label")
col.append(title)
self._alpha_sub = Gtk.Label(xalign=0)
self._alpha_sub.add_css_class("toggle-sub")
col.append(self._alpha_sub)
row.append(col)
switch = Gtk.Switch()
switch.add_css_class("alpha-switch")
switch.set_valign(Gtk.Align.CENTER)
switch.set_active(self._settings.alpha)
self._alpha_switch = switch
self._alpha_handler_id = switch.connect("state-set", self._on_alpha_toggled)
row.append(switch)
self._alpha_row = row
self._refresh_alpha_row()
return row
def _alpha_available(self) -> bool:
return PROFILES_BY_ID[self._settings.profile].alpha
def _refresh_alpha_row(self) -> None:
available = self._alpha_available()
if available:
self._alpha_row.remove_css_class("disabled")
self._alpha_sub.set_label("Available for 4444 and 4444 XQ only")
self._alpha_switch.set_sensitive(not self._encoding_locked)
else:
self._alpha_row.add_css_class("disabled")
self._alpha_sub.set_label("Requires 4444 or 4444 XQ profile")
self._alpha_switch.set_sensitive(False)
self._set_alpha_switch_silent(False)
def _on_alpha_toggled(self, _switch: Gtk.Switch, state: bool) -> bool:
if not self._alpha_available():
return True
self._settings.alpha = bool(state)
self._update_cmd_preview()
self.emit("settings-changed")
return False
def _set_alpha_switch_silent(self, on: bool) -> None:
if self._alpha_handler_id:
self._alpha_switch.handler_block(self._alpha_handler_id)
try:
self._alpha_switch.set_active(on)
finally:
if self._alpha_handler_id:
self._alpha_switch.handler_unblock(self._alpha_handler_id)
# ---------- audio bit depth toggle ----------
def _build_audio_bits_section(self) -> Gtk.Widget:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
row.add_css_class("toggle-row")
col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
col.set_hexpand(True)
title = Gtk.Label(label="24-bit audio", xalign=0)
title.add_css_class("toggle-label")
col.append(title)
sub = Gtk.Label(
label="Preserve full dynamic range from pro-camera sources. Off = 16-bit (editorial default, smaller files).",
xalign=0,
)
sub.add_css_class("toggle-sub")
sub.set_wrap(True)
sub.set_max_width_chars(40)
col.append(sub)
row.append(col)
switch = Gtk.Switch()
switch.add_css_class("alpha-switch") # re-use the accent-tinted style
switch.set_valign(Gtk.Align.CENTER)
switch.set_active(self._settings.audio_bits == 24)
self._audio_bits_switch = switch
self._audio_bits_handler_id = switch.connect("state-set", self._on_audio_bits_toggled)
row.append(switch)
return row
def _on_audio_bits_toggled(self, _switch: Gtk.Switch, state: bool) -> bool:
self._settings.audio_bits = 24 if state else 16
self._update_cmd_preview()
self.emit("settings-changed")
return False
# ---------- auto-reveal toggle ----------
def _build_auto_reveal_section(self) -> Gtk.Widget:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
row.add_css_class("toggle-row")
col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
col.set_hexpand(True)
title = Gtk.Label(label="Open output folder when done", xalign=0)
title.add_css_class("toggle-label")
col.append(title)
sub = Gtk.Label(
label="Pop the file manager open at the output folder once the queue completes.",
xalign=0,
)
sub.add_css_class("toggle-sub")
sub.set_wrap(True)
sub.set_max_width_chars(40)
col.append(sub)
row.append(col)
switch = Gtk.Switch()
switch.add_css_class("alpha-switch")
switch.set_valign(Gtk.Align.CENTER)
switch.set_active(self._settings.auto_reveal)
self._auto_reveal_switch = switch
switch.connect("state-set", self._on_auto_reveal_toggled)
row.append(switch)
return row
def _on_auto_reveal_toggled(self, _switch: Gtk.Switch, state: bool) -> bool:
self._settings.auto_reveal = bool(state)
self.emit("settings-changed")
return False
# ---------- naming ----------
def _build_naming_section(self) -> Gtk.Widget:
section = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
label = Gtk.Label(label="Output naming", xalign=0)
label.add_css_class("section-label")
section.append(label)
model = Gtk.StringList.new([
"Keep original — OriginalName.mov",
"Append suffix — OriginalName_prores_<profile>.mov",
])
dropdown = Gtk.DropDown.new(model, None)
dropdown.add_css_class("nocoder-select")
dropdown.set_selected(0 if self._settings.naming == "keep" else 1)
self._naming_dropdown = dropdown
self._naming_handler_id = dropdown.connect("notify::selected", self._on_naming_changed)
section.append(dropdown)
return section
def _on_naming_changed(self, dropdown: Gtk.DropDown, _pspec) -> None:
idx = dropdown.get_selected()
new = "keep" if idx == 0 else "suffix"
if new != self._settings.naming:
self._settings.naming = new
self._update_cmd_preview()
self.emit("settings-changed")
# ---------- output folder ----------
def _build_folder_section(self) -> Gtk.Widget:
section = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
label = Gtk.Label(label="Output folder", xalign=0)
label.add_css_class("section-label")
section.append(label)
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
row.add_css_class("folder-row")
folder_icon = Gtk.Image.new_from_icon_name("folder-symbolic")
folder_icon.add_css_class("folder-icon")
folder_icon.set_pixel_size(15)
row.append(folder_icon)
path = Gtk.Label(xalign=0)
path.add_css_class("folder-path")
path.set_hexpand(True)
path.set_ellipsize(Pango.EllipsizeMode.START)
path.set_label(self._settings.out_dir)
self._folder_path = path
row.append(path)
browse = Gtk.Button(label="Browse…")
browse.add_css_class("folder-browse")
browse.connect("clicked", lambda _b: self.emit("choose-folder-requested"))
self._browse_btn = browse
row.append(browse)
section.append(row)
return section
def set_output_folder(self, path: str) -> None:
if path and path != self._settings.out_dir:
self._settings.out_dir = path
self._folder_path.set_label(path)
self._update_cmd_preview()
self.emit("settings-changed")
# ---------- ffmpeg preview ----------
def _build_cmd_section(self) -> Gtk.Widget:
section = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
disclosure = Gtk.Button()
disclosure.add_css_class("cmd-disclosure")
disclosure.set_has_frame(False)
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
self._cmd_chevron = Gtk.Image.new_from_icon_name("pan-down-symbolic")
self._cmd_chevron.set_pixel_size(12)
row.append(self._cmd_chevron)
terminal_icon = Gtk.Image.new_from_icon_name("utilities-terminal-symbolic")
terminal_icon.set_pixel_size(13)
row.append(terminal_icon)
row.append(Gtk.Label(label="ffmpeg command preview"))
disclosure.set_child(row)
disclosure.connect("clicked", self._on_toggle_cmd)
section.append(disclosure)
self._cmd_scroller = Gtk.ScrolledWindow()
self._cmd_scroller.add_css_class("cmd-box")
self._cmd_scroller.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
self._cmd_scroller.set_min_content_height(60)
self._cmd_scroller.set_max_content_height(180)
self._cmd_view = Gtk.TextView()
self._cmd_view.set_editable(False)
self._cmd_view.set_cursor_visible(False)
self._cmd_view.set_monospace(True)
self._cmd_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
self._cmd_view.set_left_margin(0)
self._cmd_view.set_right_margin(0)
self._cmd_view.set_top_margin(0)
self._cmd_view.set_bottom_margin(0)
self._buffer = self._cmd_view.get_buffer()
# TextTag foregrounds must be concrete colours (the `foreground` property
# doesn't understand CSS named colours), so resolve them from the
# active theme — keyword = accent, flag = warning (ANSI yellow), string
# = success (ANSI green). Re-resolved on every call in case the widget
# wasn't realised the first time.
kw_hex = _resolve_theme_hex(self._cmd_view, "accent_color", "#bb9af7")
fl_hex = _resolve_theme_hex(self._cmd_view, "warning_bg_color", "#ff8c42")
str_hex = _resolve_theme_hex(self._cmd_view, "success_bg_color", "#9ece6a")
self._tag_keyword = self._buffer.create_tag("keyword", foreground=kw_hex, weight=Pango.Weight.BOLD)
self._tag_flag = self._buffer.create_tag("flag", foreground=fl_hex)
self._tag_string = self._buffer.create_tag("string", foreground=str_hex)
self._cmd_scroller.set_child(self._cmd_view)
section.append(self._cmd_scroller)
self._update_cmd_preview()
return section
def _on_toggle_cmd(self, _btn: Gtk.Button) -> None:
self._cmd_visible = not self._cmd_visible
self._cmd_scroller.set_visible(self._cmd_visible)
self._cmd_chevron.set_from_icon_name("pan-down-symbolic" if self._cmd_visible else "pan-end-symbolic")
def _update_cmd_preview(self) -> None:
if not hasattr(self, "_buffer"):
return
if self._first_file_name:
stem = Path(self._first_file_name).stem
suffix = f"_prores_{self._settings.profile}" if self._settings.naming == "suffix" else ""
out_path = f"{self._settings.out_dir.rstrip('/')}/{stem}{suffix}.mov"
text = format_preview_command(
self._first_file_name, out_path, self._settings.profile, self._settings.alpha,
audio_bits=self._settings.audio_bits,
)
else:
text = "# Add files to see the ffmpeg command"
self._buffer.set_text(text)
self._apply_highlighting()
def _apply_highlighting(self) -> None:
buf = self._buffer
start = buf.get_start_iter()
end = buf.get_end_iter()
text = buf.get_text(start, end, True)
# Tag 'ffmpeg' keyword (only the first token)
m = re.match(r"\s*ffmpeg\b", text)
if m:
s = buf.get_iter_at_offset(m.start())
e = buf.get_iter_at_offset(m.end())
buf.apply_tag(self._tag_keyword, s, e)
# Tag flags: -word and -c:v style
for m in re.finditer(r"(?<!\w)-[A-Za-z][\w:]*", text):
s = buf.get_iter_at_offset(m.start())
e = buf.get_iter_at_offset(m.end())
buf.apply_tag(self._tag_flag, s, e)
# Tag double-quoted strings
for m in re.finditer(r'"[^"\n]*"', text):
s = buf.get_iter_at_offset(m.start())
e = buf.get_iter_at_offset(m.end())
buf.apply_tag(self._tag_string, s, e)

739
nocoder/window.py Normal file
View file

@ -0,0 +1,739 @@
"""Main window: headerbar + horizontal split (queue, settings) + footer.
Owns the state machine (empty|ready|encoding|complete), file list, and the
background encode worker thread.
"""
from __future__ import annotations
import os
import shutil
import threading
from pathlib import Path
from typing import Optional
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("GdkPixbuf", "2.0")
gi.require_version("Adw", "1")
gi.require_version("Gio", "2.0")
from gi.repository import Adw, GdkPixbuf, Gio, GLib, Gtk
_ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets"
_LOGO_PATH = _ASSETS_DIR / "logo.png"
_HEADER_LOGO_SIZE = 22
from .data import (
PROFILES_BY_ID,
VIDEO_EXTENSIONS,
estimate_output_bytes,
format_bytes as _format_bytes,
is_proxy_dirname,
is_video_path,
)
from .encoder import (
EncodeJob,
detect_prores_encoder,
plan_output_path,
probe_metadata,
run_encode,
)
from .footer import Footer
from .queue_pane import FileEntry, QueuePane
from .settings_pane import Settings, SettingsPane
WINDOW_WIDTH = 1280
WINDOW_HEIGHT = 880
# Minimum usable size — deliberately small so tiling WMs (Hyprland/Sway/i3)
# can resize us into narrow tiles without pushing the footer off-screen.
# Both panes have internal scrollbars, so the content copes with compression.
WINDOW_MIN_WIDTH = 560
WINDOW_MIN_HEIGHT = 380
class MainWindow(Adw.ApplicationWindow):
__gtype_name__ = "NoCoderMainWindow"
def __init__(self, app: Adw.Application) -> None:
super().__init__(application=app)
self.set_title("NO-CODER")
self.set_default_size(WINDOW_WIDTH, WINDOW_HEIGHT)
self.set_size_request(WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT)
self.add_css_class("nocoder-window")
# App state
self._files: list[FileEntry] = []
self._selected_id: Optional[str] = None
self._state: str = "empty"
self._encoder_kind = detect_prores_encoder()
self._settings = Settings()
self._ensure_out_dir()
self._encode_thread: Optional[threading.Thread] = None
self._cancel_event: Optional[threading.Event] = None
self._active_job: Optional[EncodeJob] = None
self._current_idx: int = 0
self._current_speed: Optional[float] = None
# Root layout
root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.set_content(root)
root.append(self._build_headerbar())
split = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
split.set_hexpand(True)
split.set_vexpand(True)
root.append(split)
self._queue = QueuePane()
self._queue.connect("add-files-requested", lambda *_: self._open_files_dialog())
self._queue.connect("add-folder-requested", lambda *_: self._open_folder_dialog())
self._queue.connect("clear-requested", lambda *_: self._clear_files())
self._queue.connect("files-dropped", self._on_files_dropped)
self._queue.connect("selection-changed", self._on_selection_changed)
self._queue.connect("remove-requested", self._on_remove_requested)
split.append(self._queue)
self._settings_pane = SettingsPane(self._settings, self._encoder_kind)
self._settings_pane.connect("settings-changed", lambda *_: self._on_settings_changed())
self._settings_pane.connect("choose-folder-requested", lambda *_: self._open_out_dir_dialog())
split.append(self._settings_pane)
self._footer = Footer()
self._footer.connect("encode-requested", lambda *_: self._start_encode())
self._footer.connect("cancel-requested", lambda *_: self._cancel_encode())
self._footer.connect("reveal-requested", lambda *_: self._reveal_output_dir())
root.append(self._footer)
self._refresh_all()
self.connect("close-request", self._on_close_request)
# Keyboard shortcut: ⌃F focuses the search entry.
accel = Gtk.ShortcutController()
accel.add_shortcut(Gtk.Shortcut.new(
Gtk.ShortcutTrigger.parse_string("<Control>f"),
Gtk.CallbackAction.new(self._focus_search),
))
self.add_controller(accel)
# ---------- headerbar ----------
def _build_headerbar(self) -> Gtk.Widget:
header = Adw.HeaderBar()
header.add_css_class("nocoder-headerbar")
header.set_show_title(True)
# Left cluster: hamburger menu + search pill
left = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
hamburger = Gtk.MenuButton()
hamburger.add_css_class("icon-btn")
hamburger.set_icon_name("open-menu-symbolic")
hamburger.set_menu_model(self._build_menu_model())
left.append(hamburger)
left.append(self._build_search_pill())
header.pack_start(left)
# Center title: logo + app name + status chip
title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
title_box.set_valign(Gtk.Align.CENTER)
title_box.append(_build_header_logo())
app_name = Gtk.Label(label="NO-CODER")
app_name.add_css_class("app-title")
title_box.append(app_name)
self._status_chip = _StatusChip()
title_box.append(self._status_chip)
header.set_title_widget(title_box)
# Right cluster: toggle-settings button (+ built-in window controls)
sliders = Gtk.ToggleButton()
sliders.add_css_class("icon-btn")
sliders.set_child(Gtk.Image.new_from_icon_name("preferences-system-symbolic"))
sliders.set_tooltip_text("Show/hide settings pane")
sliders.set_active(True)
sliders.connect("toggled", self._on_settings_toggle)
self._settings_toggle = sliders
header.pack_end(sliders)
return header
def _on_settings_toggle(self, btn: Gtk.ToggleButton) -> None:
self._settings_pane.set_visible(btn.get_active())
def _build_menu_model(self) -> Gio.Menu:
menu = Gio.Menu()
menu.append("Add files…", "win.add-files")
menu.append("Add folder…", "win.add-folder")
menu.append("Clear queue", "win.clear-queue")
self._install_menu_actions()
return menu
def _install_menu_actions(self) -> None:
def add(name: str, handler):
action = Gio.SimpleAction.new(name, None)
action.connect("activate", lambda *_: handler())
self.add_action(action)
add("add-files", self._open_files_dialog)
add("add-folder", self._open_folder_dialog)
add("clear-queue", self._clear_files)
def _build_search_pill(self) -> Gtk.Widget:
pill = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
pill.add_css_class("search-pill")
icon = Gtk.Image.new_from_icon_name("system-search-symbolic")
icon.set_pixel_size(13)
pill.append(icon)
entry = Gtk.Entry()
entry.set_placeholder_text("Search files in queue…")
entry.set_has_frame(False)
entry.set_hexpand(True)
entry.set_width_chars(22)
entry.connect("changed", self._on_search_changed)
self._search_entry = entry
# Esc clears the filter and drops focus back to the queue.
esc = Gtk.ShortcutController()
esc.set_scope(Gtk.ShortcutScope.LOCAL)
esc.add_shortcut(Gtk.Shortcut.new(
Gtk.ShortcutTrigger.parse_string("Escape"),
Gtk.CallbackAction.new(self._clear_search_on_escape),
))
entry.add_controller(esc)
pill.append(entry)
kbd = Gtk.Label(label="⌃F")
kbd.add_css_class("search-kbd")
pill.append(kbd)
return pill
def _clear_search_on_escape(self, *_args) -> bool:
if not hasattr(self, "_search_entry"):
return False
if self._search_entry.get_text():
self._search_entry.set_text("")
else:
# Already empty — drop focus so Esc isn't a no-op (lets the user
# leave the search field with the keyboard).
self.grab_focus()
return True
def _focus_search(self, *_args) -> bool:
if hasattr(self, "_search_entry"):
self._search_entry.grab_focus()
return True
return False
def _on_search_changed(self, entry: Gtk.Entry) -> None:
self._queue.set_search_query(entry.get_text())
# ---------- state plumbing ----------
def _compute_state(self) -> str:
if self._state == "encoding":
return "encoding"
if self._state == "complete":
# Stay in complete until user encodes again or clears.
return "complete"
if not self._files:
return "empty"
return "ready"
def _refresh_all(self) -> None:
self._state = self._compute_state() if self._state not in ("encoding", "complete") else self._state
self._status_chip.set_state(self._state)
self._queue.set_files(self._files)
if self._selected_id is not None:
self._queue.set_selected(self._selected_id)
self._queue.set_encoding(self._state == "encoding")
first_name = self._files[0].name if self._files else None
self._settings_pane.set_first_file_name(first_name)
self._settings_pane.set_encoding(self._state == "encoding")
self._settings_pane.refresh()
self._footer.update(
state="ready" if self._state in ("empty", "ready") else self._state,
files=self._files,
profile_id=self._settings.profile,
overall=self._overall_progress(),
current_idx=self._current_idx,
)
def _overall_progress(self) -> float:
if not self._files:
return 0.0
total = 0.0
for f in self._files:
if f.status == "done":
total += 1.0
elif f.status == "encoding":
total += max(0.0, min(1.0, f.progress))
return total / len(self._files)
def _on_settings_changed(self) -> None:
# Recompute est_out using the new profile's bitrate.
mbps = PROFILES_BY_ID[self._settings.profile].mbps
for f in self._files:
f.est_out = estimate_output_bytes(f.meta.duration, mbps)
self._queue.update_file(f)
# Footer and preview need refresh.
self._footer.update(
state="ready" if self._state in ("empty", "ready") else self._state,
files=self._files,
profile_id=self._settings.profile,
overall=self._overall_progress(),
current_idx=self._current_idx,
)
def _ensure_out_dir(self) -> None:
if not self._settings.out_dir:
self._settings.out_dir = str(Path.home() / "Footage" / "prores")
try:
Path(self._settings.out_dir).mkdir(parents=True, exist_ok=True)
except OSError:
pass
# ---------- file operations ----------
def _open_files_dialog(self) -> None:
dialog = Gtk.FileDialog()
dialog.set_title("Choose videos")
dialog.set_modal(True)
filters = Gio.ListStore.new(Gtk.FileFilter)
video_filter = Gtk.FileFilter()
video_filter.set_name("Video files")
for ext in VIDEO_EXTENSIONS:
video_filter.add_pattern(f"*{ext}")
video_filter.add_pattern(f"*{ext.upper()}")
filters.append(video_filter)
any_filter = Gtk.FileFilter()
any_filter.set_name("All files")
any_filter.add_pattern("*")
filters.append(any_filter)
dialog.set_filters(filters)
dialog.open_multiple(self, None, self._on_files_chosen)
def _on_files_chosen(self, dialog: Gtk.FileDialog, result) -> None:
try:
model = dialog.open_multiple_finish(result)
except GLib.Error:
return
paths: list[str] = []
for i in range(model.get_n_items()):
f = model.get_item(i)
if f is None:
continue
p = f.get_path()
if p:
paths.append(p)
self._add_paths(paths)
def _open_folder_dialog(self) -> None:
dialog = Gtk.FileDialog()
dialog.set_title("Choose folder")
dialog.set_modal(True)
dialog.select_folder(self, None, self._on_folder_chosen)
def _on_folder_chosen(self, dialog: Gtk.FileDialog, result) -> None:
try:
f = dialog.select_folder_finish(result)
except GLib.Error:
return
if f is None:
return
path = f.get_path()
if not path:
return
paths: list[str] = []
for root, dirs, files in os.walk(path):
# Prune proxy / thumbnail / metadata subdirs in-place so os.walk
# doesn't recurse into them — avoids pulling low-res duplicates
# from Sony SUB/, Panasonic PROXY/ etc. into the queue alongside
# the master clips.
dirs[:] = [d for d in dirs if not is_proxy_dirname(d)]
for name in files:
full = os.path.join(root, name)
if is_video_path(full):
paths.append(full)
paths.sort()
self._add_paths(paths)
def _open_out_dir_dialog(self) -> None:
dialog = Gtk.FileDialog()
dialog.set_title("Choose output folder")
dialog.set_modal(True)
try:
dialog.set_initial_folder(Gio.File.new_for_path(self._settings.out_dir))
except GLib.Error:
pass
dialog.select_folder(self, None, self._on_out_dir_chosen)
def _on_out_dir_chosen(self, dialog: Gtk.FileDialog, result) -> None:
try:
f = dialog.select_folder_finish(result)
except GLib.Error:
return
if f is None:
return
path = f.get_path()
if path:
self._settings_pane.set_output_folder(path)
def _on_files_dropped(self, _pane, paths: list[str]) -> None:
expanded: list[str] = []
for p in paths:
if os.path.isdir(p):
for root, dirs, files in os.walk(p):
# Skip proxy / thumbnail / metadata dirs (Sony SUB,
# Panasonic PROXY, etc.) — see data.PROXY_DIRNAMES.
dirs[:] = [d for d in dirs if not is_proxy_dirname(d)]
for name in files:
full = os.path.join(root, name)
if is_video_path(full):
expanded.append(full)
elif os.path.isfile(p) and is_video_path(p):
expanded.append(p)
self._add_paths(expanded)
def _add_paths(self, paths: list[str]) -> None:
# Dedupe by realpath so the same physical file added via different
# mount points (e.g. /run/media/gav/Card and /run/media/gav/Card1)
# or symlinks doesn't appear twice.
existing = {os.path.realpath(f.path) for f in self._files}
mbps = PROFILES_BY_ID[self._settings.profile].mbps
added: list[FileEntry] = []
for p in paths:
if not p or not os.path.isfile(p):
continue
real = os.path.realpath(p)
if real in existing:
continue
try:
size = os.path.getsize(p)
except OSError:
size = 0
entry = FileEntry(path=p, size=size)
entry.est_out = 0.0
self._files.append(entry)
existing.add(real)
added.append(entry)
if not added:
return
# Move out of "complete" state when user adds more work.
if self._state == "complete":
self._state = "ready"
self._reset_file_statuses()
self._refresh_all()
for entry in added:
self._probe_async(entry, mbps)
def _probe_async(self, entry: FileEntry, mbps: int) -> None:
def worker() -> None:
meta = probe_metadata(entry.path)
def apply() -> bool:
entry.meta = meta
entry.est_out = estimate_output_bytes(meta.duration, mbps)
self._queue.update_file(entry)
self._footer.update(
state="ready" if self._state in ("empty", "ready") else self._state,
files=self._files,
profile_id=self._settings.profile,
overall=self._overall_progress(),
current_idx=self._current_idx,
)
self._settings_pane.set_first_file_name(self._files[0].name if self._files else None)
return False
GLib.idle_add(apply)
t = threading.Thread(target=worker, daemon=True)
t.start()
def _clear_files(self) -> None:
if self._state == "encoding":
return
self._files.clear()
self._selected_id = None
self._state = "empty"
if hasattr(self, "_search_entry"):
self._search_entry.set_text("")
self._refresh_all()
def _reset_file_statuses(self) -> None:
for f in self._files:
f.status = "queued"
f.progress = 0.0
f.error = None
def _on_selection_changed(self, _pane, file_id: str) -> None:
self._selected_id = file_id or None
def _on_remove_requested(self, _pane, file_id: str) -> None:
self._files = [f for f in self._files if f.id != file_id]
if self._selected_id == file_id:
self._selected_id = None
if not self._files:
self._state = "empty"
self._refresh_all()
# ---------- encode ----------
def _start_encode(self) -> None:
if self._state == "encoding" or not self._files:
return
if self._encoder_kind == "none":
self._show_error("No ProRes encoder found.\nInstall ffmpeg with prores_ks or prores support.")
return
try:
Path(self._settings.out_dir).mkdir(parents=True, exist_ok=True)
except OSError as e:
self._show_error(f"Cannot create output folder:\n{e}")
return
if not os.access(self._settings.out_dir, os.W_OK):
self._show_error(f"No write permission for output folder:\n{self._settings.out_dir}")
return
# Disk-space pre-check — sum the estimated output sizes of every
# queued (or queue-able) file and compare against free bytes on the
# output volume. Cheap insurance against a half-finished batch when
# someone forgets the destination is nearly full.
try:
need = sum(f.est_out for f in self._files if f.est_out)
free = shutil.disk_usage(self._settings.out_dir).free
except OSError:
need = 0
free = 0
if need and free and need > free:
self._show_error(
f"Not enough free space in {self._settings.out_dir}\n"
f"Need ≈{_format_bytes(need)}, available {_format_bytes(free)}."
)
return
self._reset_file_statuses()
self._state = "encoding"
self._current_idx = 0
self._cancel_event = threading.Event()
self._refresh_all()
self._encode_thread = threading.Thread(target=self._encode_worker, daemon=True)
self._encode_thread.start()
def _encode_worker(self) -> None:
cancel = self._cancel_event
assert cancel is not None
for idx, entry in enumerate(list(self._files)):
if cancel.is_set():
break
# Source file may have moved/been-deleted between probe time and
# now. Mark it failed instead of letting ffmpeg emit a cryptic
# "no such file" error.
if not os.path.isfile(entry.path):
GLib.idle_add(self._finish_file, entry.id, False, "source file is missing")
continue
GLib.idle_add(self._set_current_encoding, idx, entry.id)
out_path = plan_output_path(
entry.path,
self._settings.out_dir,
self._settings.naming,
self._settings.profile,
)
done_event = threading.Event()
result = {"ok": False, "err": None}
def on_prog(pct: float, _entry=entry) -> None:
GLib.idle_add(self._apply_file_progress, _entry.id, pct)
def on_done(ok: bool, err, _entry=entry) -> None:
result["ok"] = ok
result["err"] = err
done_event.set()
def on_speed(spd: float) -> None:
GLib.idle_add(self._apply_speed, spd)
job = EncodeJob(
src=entry.path,
out=out_path,
duration=entry.meta.duration or 0.0,
on_progress=on_prog,
on_done=on_done,
on_speed=on_speed,
audio_stream_indexes=list(entry.meta.audio_stream_indexes),
cancel_event=cancel,
)
# Track the active job so _on_close_request can cancel the live
# ffmpeg child synchronously rather than relying on the worker's
# next stdout-loop iteration.
self._active_job = job
try:
run_encode(
job, self._settings.profile, self._settings.alpha, self._encoder_kind,
audio_bits=self._settings.audio_bits,
)
done_event.wait(timeout=5)
finally:
self._active_job = None
GLib.idle_add(self._finish_file, entry.id, bool(result["ok"]), result["err"])
GLib.idle_add(self._finish_encoding)
def _set_current_encoding(self, idx: int, file_id: str) -> bool:
self._current_idx = idx
# Reset the live-speed reading at every file boundary so the footer
# doesn't briefly show the previous file's speed before ffmpeg
# publishes the first `speed=` for the new one.
self._current_speed = None
for f in self._files:
if f.id == file_id:
f.status = "encoding"
f.progress = 0.0
self._queue.update_file(f)
break
self._footer.update(
state="encoding",
files=self._files,
profile_id=self._settings.profile,
overall=self._overall_progress(),
current_idx=self._current_idx,
speed=self._current_speed,
)
return False
def _apply_file_progress(self, file_id: str, pct: float) -> bool:
for f in self._files:
if f.id == file_id:
f.progress = max(0.0, min(1.0, pct))
self._queue.update_file(f)
break
self._footer.update(
state="encoding",
files=self._files,
profile_id=self._settings.profile,
overall=self._overall_progress(),
current_idx=self._current_idx,
speed=self._current_speed,
)
return False
def _apply_speed(self, speed: float) -> bool:
self._current_speed = speed
if self._state == "encoding":
self._footer.update(
state="encoding",
files=self._files,
profile_id=self._settings.profile,
overall=self._overall_progress(),
current_idx=self._current_idx,
speed=self._current_speed,
)
return False
def _finish_file(self, file_id: str, ok: bool, err) -> bool:
for f in self._files:
if f.id == file_id:
f.status = "done" if ok else "failed"
f.progress = 1.0 if ok else 0.0
f.error = None if ok else (err or "encode failed")
self._queue.update_file(f)
break
return False
def _finish_encoding(self) -> bool:
cancelled = self._cancel_event is not None and self._cancel_event.is_set()
self._cancel_event = None
self._encode_thread = None
if cancelled:
# Anything still in 'encoding' status becomes 'queued' again.
for f in self._files:
if f.status == "encoding":
f.status = "queued"
f.progress = 0.0
self._state = "ready"
else:
self._state = "complete"
# 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._refresh_all()
return False
def _cancel_encode(self) -> None:
if self._cancel_event is not None:
self._cancel_event.set()
def _reveal_output_dir(self) -> None:
try:
uri = Gio.File.new_for_path(self._settings.out_dir).get_uri()
Gio.AppInfo.launch_default_for_uri(uri, None)
except GLib.Error:
pass
def _on_close_request(self, *_args) -> bool:
# Tell the worker to stop iterating.
if self._cancel_event is not None:
self._cancel_event.set()
# Actively terminate the live ffmpeg child — the worker thread is a
# daemon so it dies with Python on window close, but ffmpeg is its own
# process and would keep running + writing a partial .mov otherwise.
job = getattr(self, "_active_job", None)
if job is not None:
job.cancel()
return False
def _show_error(self, message: str) -> None:
dialog = Adw.MessageDialog.new(self, "NO-CODER", message)
dialog.add_response("ok", "OK")
dialog.set_default_response("ok")
dialog.present()
def _build_header_logo() -> Gtk.Widget:
"""22×22 NO-CODER mark shown in the headerbar.
Pre-scales the PNG to 2× for HiDPI, wraps it in a Gtk.Image and fixes the
display size via set_pixel_size. Falls back to the previous symbolic icon
(with the old orange tile styling) if the asset is missing.
"""
if _LOGO_PATH.exists():
try:
pb = GdkPixbuf.Pixbuf.new_from_file_at_scale(
str(_LOGO_PATH), _HEADER_LOGO_SIZE * 2, _HEADER_LOGO_SIZE * 2, True,
)
img = Gtk.Image.new_from_pixbuf(pb)
img.set_pixel_size(_HEADER_LOGO_SIZE)
img.add_css_class("app-logo-image")
return img
except GLib.Error:
pass
logo = Gtk.Image.new_from_icon_name("video-x-generic-symbolic")
logo.add_css_class("app-logo")
logo.set_pixel_size(14)
return logo
class _StatusChip(Gtk.Box):
"""Small colored pill with a dot + label. States: idle|ready|encoding|done."""
def __init__(self) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
self.add_css_class("status-chip")
self.add_css_class("idle")
self._state = "idle"
inner = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
self._dot = Gtk.Box()
self._dot.add_css_class("dot")
self._dot.set_valign(Gtk.Align.CENTER)
inner.append(self._dot)
self._label = Gtk.Label(label="IDLE")
inner.append(self._label)
self.append(inner)
def set_state(self, state: str) -> None:
mapping = {
"empty": ("idle", "IDLE"),
"ready": ("ready", "READY"),
"encoding": ("encoding", "ENCODING"),
"complete": ("done", "DONE"),
}
cls, text = mapping.get(state, ("idle", "IDLE"))
for c in ("idle", "ready", "encoding", "done"):
self.remove_css_class(c)
self.add_css_class(cls)
self._label.set_label(text)
self._state = cls

View file

@ -0,0 +1,13 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=NO-CODER
GenericName=Video Transcoder
Comment=Batch convert videos to Apple ProRes .mov
Exec=@LAUNCHER@ %F
Icon=dev.nocoder.NoCoder
Terminal=false
Categories=AudioVideo;
StartupWMClass=dev.nocoder.NoCoder
MimeType=video/mp4;video/quicktime;video/x-matroska;video/x-msvideo;video/webm;video/x-m4v;video/mp2t;
Keywords=prores;ffmpeg;transcode;convert;video;

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

28
run.py Executable file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""NO-CODER launcher.
Usage:
python3 run.py [FILE_OR_DIR ...]
Optional positional args are treated as files/directories to pre-populate the queue.
"""
from __future__ import annotations
import sys
from pathlib import Path
# Allow running from anywhere — make this directory importable.
_here = Path(__file__).resolve().parent
if str(_here) not in sys.path:
sys.path.insert(0, str(_here))
from nocoder.app import NoCoderApplication # noqa: E402
def main() -> int:
app = NoCoderApplication()
return app.run(sys.argv)
if __name__ == "__main__":
raise SystemExit(main())

782
style.css Normal file
View file

@ -0,0 +1,782 @@
/* NO-CODER theme-aware styling.
Backgrounds, foregrounds, borders, and popover/dialog/headerbar chrome are
derived from the user's active Omarchy theme via its `gtk.css`, which is
loaded in app.py at GTK_STYLE_PROVIDER_PRIORITY_THEME before this file.
The ProRes-orange accent, semantic states (success/danger/info/highlight),
and brand-critical bits are hardcoded they're the app's identity and
should stay put across Kanagawa, Catppuccin, Tokyo Night, etc.
Fallback: if Omarchy isn't present or the theme file is missing, the
libadwaita defaults apply; the app still renders coherently. */
/* Internal palette cascades from the Omarchy-provided libadwaita tokens.
Existing rules throughout this file that reference @window_bg / @text_main /
@surface etc. transparently pick up whichever theme is active. */
@define-color base @window_bg_color;
@define-color window_bg @window_bg_color;
@define-color surface @view_bg_color;
@define-color elevated shade(@window_bg_color, 1.18);
@define-color elevated_2 shade(@window_bg_color, 1.35);
@define-color border_muted alpha(@window_fg_color, 0.15);
@define-color border_strong alpha(@window_fg_color, 0.25);
@define-color text_main @window_fg_color;
@define-color text_muted alpha(@window_fg_color, 0.72);
@define-color text_dim alpha(@window_fg_color, 0.55);
@define-color text_faint alpha(@window_fg_color, 0.38);
/* Accent + semantic tokens cascade from whatever Omarchy theme is active.
The synthesised theme CSS in app.py defines @accent_color, @destructive_bg_color,
@success_bg_color, @warning_bg_color, @error_bg_color from the theme's
`accent` + ANSI palette (color1 red / color2 green / color3 yellow / color4
blue). So nothing hardcoded here the app adheres to the system theme. */
@define-color accent @accent_color;
@define-color accent_soft alpha(@accent_color, 0.14);
@define-color accent_tint alpha(@accent_color, 0.22);
@define-color accent_ring alpha(@accent_color, 0.38);
@define-color accent_glow alpha(@accent_color, 0.55);
@define-color success @success_bg_color;
@define-color danger @destructive_bg_color;
@define-color info @accent_color;
@define-color highlight @warning_bg_color;
window.nocoder-window,
window.nocoder-window > * {
background-color: @window_bg;
color: @text_main;
font-family: "Inter", "Adwaita Sans", "Cantarell", sans-serif;
font-size: 13px;
}
window.nocoder-window {
border-radius: 12px;
}
/* ---------------- Headerbar ---------------- */
.nocoder-headerbar {
background-image: linear-gradient(to bottom, @elevated 0%, shade(@elevated, 0.92) 100%);
background-color: @elevated;
border-bottom: 1px solid @border_muted;
min-height: 46px;
padding: 0 10px;
color: @text_muted;
}
.nocoder-headerbar windowcontrols {
margin: 0 2px;
}
.nocoder-headerbar windowcontrols > button {
min-width: 22px;
min-height: 22px;
padding: 0;
margin: 0 3px;
border-radius: 999px;
background: transparent;
color: @text_muted;
border: none;
box-shadow: none;
}
.nocoder-headerbar windowcontrols > button:hover {
background-color: alpha(@text_main, 0.08);
color: @text_main;
}
.app-title {
font-size: 14px;
font-weight: 600;
color: @text_main;
}
.app-logo {
min-width: 22px; min-height: 22px;
border-radius: 6px;
background-image: linear-gradient(135deg, @accent 0%, alpha(@accent, 0.67) 100%);
box-shadow: 0 0 0 1px @accent_ring, 0 2px 6px alpha(@accent, 0.22);
color: @window_bg;
padding: 3px;
}
.status-chip {
padding: 2px 10px;
border-radius: 999px;
font-family: "JetBrains Mono", monospace;
font-weight: 500;
font-size: 11px;
letter-spacing: 0.3px;
border: 1px solid transparent;
}
.status-chip box { /* dot + label spacing */ }
.status-chip .dot {
min-width: 6px; min-height: 6px;
border-radius: 999px;
margin-right: 6px;
}
.status-chip.idle { color: @text_dim; background-color: alpha(@text_dim, 0.14); border-color: alpha(@text_dim, 0.2); }
.status-chip.idle .dot { background-color: @text_dim; }
.status-chip.ready { color: @success; background-color: alpha(@success, 0.14); border-color: alpha(@success, 0.2); }
.status-chip.ready .dot { background-color: @success; box-shadow: 0 0 6px @success; }
.status-chip.encoding { color: @accent; background-color: @accent_soft; border-color: @accent_ring; }
.status-chip.encoding .dot { background-color: @accent; box-shadow: 0 0 6px @accent; }
.status-chip.done { color: @info; background-color: alpha(@info, 0.14); border-color: alpha(@info, 0.2); }
.status-chip.done .dot { background-color: @info; box-shadow: 0 0 6px @info; }
/* Search pill (custom SearchEntry frame) */
.search-pill {
background-color: @surface;
border: 1px solid @border_muted;
border-radius: 999px;
padding: 0 12px;
min-height: 30px;
color: @text_dim;
}
.search-pill text,
.search-pill entry {
background: transparent;
color: @text_main;
border: none;
padding: 0 6px;
min-height: 28px;
caret-color: @accent;
box-shadow: none;
}
.search-pill text placeholder,
.search-pill entry placeholder {
color: @text_dim;
opacity: 1;
}
.search-pill .search-kbd {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
color: @text_dim;
padding: 1px 6px;
background-color: @border_muted;
border-radius: 4px;
}
/* Headerbar icon buttons (menu, sliders) */
.icon-btn {
min-width: 32px; min-height: 32px;
border-radius: 999px;
padding: 0;
background: transparent;
color: @text_muted;
border: none;
box-shadow: none;
}
.icon-btn:hover { background-color: alpha(@text_main, 0.08); color: @text_main; }
.icon-btn:active { background-color: alpha(@text_main, 0.14); }
/* ---------------- Panes ---------------- */
.queue-pane {
background-color: @surface;
border-right: 1px solid @border_muted;
}
.settings-pane {
background-color: @window_bg;
}
.settings-pane scrolledwindow,
.queue-pane scrolledwindow {
background: transparent;
}
.pane-header {
min-height: 42px;
padding: 0 16px;
border-bottom: 1px solid @border_muted;
}
.pane-label {
font-size: 11px;
font-weight: 600;
color: @text_dim;
letter-spacing: 1.5px;
}
.pane-label.upper { }
.count-chip {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
padding: 1px 10px;
background-color: @border_muted;
color: @text_main;
border-radius: 999px;
font-weight: 500;
}
.count-chip.secondary {
background-color: transparent;
color: @text_dim;
font-size: 10px;
padding: 1px 6px;
}
.encoder-chip {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
color: @text_faint;
}
/* ---------------- Queue action bar ---------------- */
.action-bar {
padding: 10px 12px;
border-bottom: 1px solid @border_muted;
}
.muted-btn {
background-color: @elevated;
border: 1px solid @border_muted;
color: @text_main;
border-radius: 8px;
padding: 0 12px;
min-height: 30px;
font-size: 13px;
font-weight: 500;
box-shadow: none;
}
.muted-btn:hover { background-color: shade(@elevated, 1.1); }
.muted-btn:disabled { opacity: 0.4; }
.muted-btn.clear-btn { color: @text_dim; }
.muted-btn.accent-outline {
color: @accent;
background-color: @accent_tint;
border-color: @accent_ring;
}
/* ---------------- File rows ---------------- */
.queue-list {
background: transparent;
padding: 6px 8px;
}
.queue-list row {
background: transparent;
margin: 2px 0;
padding: 0;
border-radius: 8px;
}
.queue-list row.selected {
background-color: @accent_soft;
}
.queue-list row:selected,
.queue-list row:selected:focus {
background-color: @accent_soft;
}
.file-row {
padding: 11px 12px;
border-radius: 8px;
border: 1px solid transparent;
}
.file-row.selected {
background-color: @accent_soft;
border-color: @accent_ring;
}
.file-thumb {
min-width: 28px; min-height: 28px;
border-radius: 6px;
background-image: linear-gradient(135deg, @elevated_2 0%, @elevated 100%);
border: 1px solid @border_strong;
color: @text_dim;
padding: 5px;
}
.filename {
font-size: 13px;
font-weight: 500;
color: @text_main;
font-feature-settings: "tnum";
}
.file-meta {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
color: @text_dim;
}
.file-meta label.sep { color: @border_strong; }
.file-meta label.alpha-mark { color: @accent; }
.file-size {
font-family: "JetBrains Mono", monospace;
font-size: 12px;
color: @text_muted;
font-feature-settings: "tnum";
}
.file-estout {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
color: @text_faint;
font-feature-settings: "tnum";
}
.file-progress progress,
.file-progress trough {
min-height: 3px;
border-radius: 999px;
}
.file-progress trough {
background-color: @border_muted;
border: none;
}
.file-progress progress {
background-color: @accent;
background-image: none;
box-shadow: 0 0 8px alpha(@accent, 0.67);
border: none;
}
.file-progress {
margin-top: 6px;
}
/* Status dot */
.status-dot {
min-width: 16px; min-height: 16px;
border-radius: 999px;
border: 1px solid @border_strong;
background: transparent;
padding: 2px;
}
.status-dot.queued { border-color: @border_strong; }
.status-dot.encoding { border-color: @accent; background-color: alpha(@accent, 0.2); color: @accent; }
.status-dot.done { border-color: @success; background-color: alpha(@success, 0.14); color: @success; }
.status-dot.failed { border-color: @danger; background-color: alpha(@danger, 0.14); color: @danger; }
/* Per-row remove button — hidden until row hover. */
button.file-row-remove {
opacity: 0;
min-width: 22px; min-height: 22px;
padding: 3px;
border-radius: 6px;
border: none;
background: transparent;
color: @text_dim;
transition: opacity 120ms, color 120ms, background-color 120ms;
}
button.file-row-remove image { -gtk-icon-size: 12px; }
.queue-list row:hover button.file-row-remove,
button.file-row-remove:focus {
opacity: 1;
}
button.file-row-remove:hover {
color: @danger;
background-color: alpha(@danger, 0.14);
}
button.file-row-remove:disabled {
opacity: 0;
}
/* Search-filter placeholder inside the queue list. */
.queue-empty-matches {
color: @text_dim;
font-size: 12px;
padding: 20px 12px;
}
/* ---------------- Drop zone ---------------- */
.drop-zone {
margin: 14px;
border: 2px dashed @border_strong;
border-radius: 14px;
padding: 32px;
color: @text_dim;
background-image:
repeating-linear-gradient(45deg,
transparent 0px, transparent 10px,
alpha(@text_main, 0.015) 10px, alpha(@text_main, 0.015) 20px);
}
.drop-icon {
min-width: 72px; min-height: 72px;
border-radius: 20px;
background-image: linear-gradient(135deg, @accent_ring 0%, alpha(@accent, 0.07) 100%);
border: 1px solid alpha(@accent, 0.33);
color: @accent;
padding: 16px;
}
.drop-heading {
font-size: 16px;
font-weight: 600;
color: @text_main;
}
.drop-sub {
font-size: 13px;
color: @text_dim;
}
.drop-sub .link {
color: @accent;
font-weight: 500;
}
.drop-hint {
font-size: 11px;
color: @text_faint;
}
.drop-hint .code {
font-family: "JetBrains Mono", monospace;
}
.drop-zone.drop-hover {
border-color: @accent;
background-color: @accent_soft;
}
/* ---------------- Settings sections ---------------- */
.section-label {
font-size: 12px;
font-weight: 600;
color: @text_main;
}
.section-sublabel {
font-size: 11px;
color: @text_dim;
}
.profile-row {
padding: 10px 12px;
border-radius: 8px;
border: 1px solid @border_muted;
background-color: @surface;
margin-bottom: 6px;
}
.profile-row.selected {
border-color: @accent;
background-color: alpha(@accent, 0.1);
}
.profile-row:hover { background-color: shade(@surface, 1.08); }
.profile-row.selected:hover { background-color: alpha(@accent, 0.14); }
.profile-radio-outer {
min-width: 16px; min-height: 16px;
border-radius: 999px;
border: 2px solid @text_faint;
background: transparent;
padding: 0;
}
.profile-radio-outer.selected {
border-color: @accent;
border-width: 5px;
background-color: @surface;
}
.profile-name {
font-size: 13px;
font-weight: 600;
color: @text_main;
}
.profile-name .alpha-tag {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
color: @text_dim;
font-weight: 400;
margin-left: 6px;
}
.profile-desc {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
color: @text_dim;
}
.profile-badge {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
font-weight: 600;
color: @accent;
background-color: alpha(@accent, 0.2);
padding: 2px 8px;
border-radius: 4px;
letter-spacing: 0.5px;
}
/* Alpha toggle */
.toggle-row {
padding: 10px 12px;
background-color: @surface;
border: 1px solid @border_muted;
border-radius: 8px;
}
.toggle-row.disabled {
opacity: 0.4;
}
.toggle-label {
font-size: 13px;
color: @text_main;
font-weight: 500;
}
.toggle-sub {
font-size: 11px;
color: @text_dim;
}
switch.alpha-switch {
min-width: 36px;
min-height: 20px;
border-radius: 999px;
background-color: @border_strong;
border: none;
box-shadow: none;
padding: 0;
}
switch.alpha-switch:checked {
background-color: @accent;
}
switch.alpha-switch slider {
min-width: 16px;
min-height: 16px;
border-radius: 999px;
background-color: #ffffff;
border: none;
box-shadow: 0 1px 3px alpha(#000000, 0.4);
margin: 0;
}
/* Naming dropdown */
dropdown.nocoder-select,
dropdown.nocoder-select > button {
min-height: 36px;
background-color: @surface;
border: 1px solid @border_muted;
border-radius: 8px;
color: @text_main;
padding: 0 12px;
font-size: 13px;
box-shadow: none;
}
dropdown.nocoder-select > button:hover { background-color: shade(@surface, 1.1); }
dropdown.nocoder-select > button arrow { color: @text_dim; }
/* Output folder row */
.folder-row {
padding: 10px 12px;
background-color: @surface;
border: 1px solid @border_muted;
border-radius: 8px;
}
.folder-icon {
color: @accent;
}
.folder-path {
font-family: "JetBrains Mono", monospace;
font-size: 12px;
color: @text_main;
}
button.folder-browse {
padding: 3px 10px;
min-height: 24px;
background-color: @elevated;
border: 1px solid @border_strong;
border-radius: 6px;
color: @text_muted;
font-size: 11px;
box-shadow: none;
}
button.folder-browse:hover { background-color: shade(@elevated, 1.1); color: @text_main; }
/* Collapsible command preview */
button.cmd-disclosure {
background: transparent;
border: none;
color: @text_main;
padding: 4px 2px;
box-shadow: none;
font-weight: 600;
font-size: 12px;
}
button.cmd-disclosure:hover { color: @text_main; background-color: alpha(@text_main, 0.05); }
.cmd-box {
background-color: @base;
border: 1px solid @border_muted;
border-radius: 8px;
padding: 12px;
color: @text_muted;
font-family: "JetBrains Mono", monospace;
font-size: 11px;
}
.cmd-box textview,
.cmd-box textview text {
background: transparent;
color: @text_muted;
font-family: "JetBrains Mono", monospace;
font-size: 11px;
caret-color: @accent;
}
/* ---------------- Footer ---------------- */
.footer-bar {
min-height: 72px;
border-top: 1px solid @border_muted;
background-image: linear-gradient(to bottom, @window_bg 0%, shade(@window_bg, 0.9) 100%);
padding: 0 18px;
}
.footer-divider {
min-width: 1px; min-height: 28px;
background-color: @border_muted;
}
.stat-label {
font-size: 10px;
color: @text_dim;
letter-spacing: 1px;
font-weight: 600;
}
.stat-value {
font-size: 15px;
color: @text_main;
font-weight: 600;
font-family: "JetBrains Mono", monospace;
font-feature-settings: "tnum";
}
.stat-value-sm {
font-size: 13px;
color: @text_main;
font-weight: 500;
font-family: "JetBrains Mono", monospace;
font-feature-settings: "tnum";
}
.stat-value.success { color: @success; }
.stat-value.danger { color: @danger; }
.stat-arrow { color: @text_faint; }
.stat-in { color: @text_dim; }
.stat-out { color: @accent; }
button.encode-cta {
min-height: 42px;
padding: 0 22px;
background-image: linear-gradient(to bottom, @accent 0%, shade(@accent, 0.88) 100%);
background-color: @accent;
border: 1px solid @accent;
color: @window_bg;
border-radius: 10px;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.2px;
box-shadow: 0 4px 14px @accent_glow, inset 0 1px 0 alpha(#ffffff, 0.2);
}
button.encode-cta:hover { background-image: linear-gradient(to bottom, shade(@accent, 1.08) 0%, @accent 100%); }
button.encode-cta:disabled {
background-image: none;
background-color: @border_muted;
border-color: @border_strong;
color: @text_faint;
box-shadow: none;
}
button.cancel-btn {
min-height: 42px;
padding: 0 18px;
background-color: alpha(@danger, 0.13);
border: 1px solid @danger;
color: @danger;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
box-shadow: none;
}
button.cancel-btn:hover { background-color: alpha(@danger, 0.18); }
button.reveal-btn {
min-height: 42px;
padding: 0 16px;
background: transparent;
border: 1px solid @border_strong;
color: @text_main;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
box-shadow: none;
}
button.reveal-btn:hover { background-color: alpha(@text_main, 0.05); }
.overall-progress progress,
.overall-progress trough {
min-height: 6px;
border-radius: 999px;
}
.overall-progress trough {
background-color: @border_muted;
border: none;
}
.overall-progress progress {
background-image: linear-gradient(to right, alpha(@accent, 0.67) 0%, @accent 100%);
border: none;
}
.progress-title {
font-family: "JetBrains Mono", monospace;
font-size: 12px;
color: @text_main;
}
.progress-title .idx { color: @text_dim; }
.progress-title .pct { color: @accent; font-weight: 600; }
.progress-status {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
color: @text_dim;
}
.progress-status .ok { color: @success; }
.progress-status .fail { color: @danger; }
/* Scrollbars: keep slim and subtle */
scrollbar {
background: transparent;
border: none;
}
scrollbar slider {
min-width: 8px;
min-height: 8px;
border-radius: 8px;
background-color: @border_strong;
}
scrollbar slider:hover { background-color: @border_strong; }
/* ---------------- Dialogs & popovers ---------------- */
/* The named-colour overrides above handle most of the theming. The rules
below nudge a few surfaces (border radius, spacing, hover affordances) that
libadwaita doesn't derive cleanly from tokens alone. */
window.messagedialog,
window.messagedialog .dialog-vbox {
background-color: @window_bg;
color: @text_main;
}
window.messagedialog .dialog-vbox {
border-radius: 12px;
}
window.messagedialog label.title {
color: @text_main;
font-weight: 600;
}
window.messagedialog label.body {
color: @text_muted;
}
popover > contents,
popover.menu > contents {
background-color: @window_bg;
border: 1px solid @border_muted;
border-radius: 10px;
padding: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
popover modelbutton,
popover > contents button {
border-radius: 6px;
padding: 6px 10px;
color: @text_main;
background: transparent;
}
popover modelbutton:hover,
popover > contents button:hover {
background-color: alpha(@text_main, 0.08);
}
popover modelbutton:active,
popover modelbutton:selected,
popover > contents button:active {
background-color: @accent_soft;
color: @text_main;
}
/* Gtk.FileDialog / legacy file chooser only reached when GTK_USE_PORTAL=0
routes the picker through our in-process widgets. */
filechooser {
background-color: @window_bg;
color: @text_main;
}
filechooser placessidebar {
background-color: @surface;
}
filechooser placessidebar row:selected {
background-color: @accent_soft;
color: @text_main;
}

61
uninstall.sh Executable file
View file

@ -0,0 +1,61 @@
#!/usr/bin/env bash
# uninstall.sh — reverse what install.sh did.
# Leaves pacman packages alone (they may be needed by other apps).
set -euo pipefail
APP_ID="dev.nocoder.NoCoder"
LAUNCHER_NAME="nocoder"
BIN_DIR="$HOME/.local/bin"
DESKTOP_DIR="$HOME/.local/share/applications"
HICOLOR_DIR="$HOME/.local/share/icons/hicolor"
INSTALL_DIR="$HOME/.local/share/nocoder"
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/nocoder"
HYPR_CONF="$HOME/.config/hypr/windows.conf"
MARK_BEGIN="# >>> nocoder windowrules begin"
MARK_END="# <<< nocoder windowrules end"
GREEN=$'\e[32m'; DIM=$'\e[2m'; RESET=$'\e[0m'
say() { printf '%s==>%s %s\n' "$GREEN" "$RESET" "$*"; }
say "Removing installed app tree, launcher, desktop entry, icons, config"
rm -rf "$INSTALL_DIR"
rm -rf "$CONFIG_DIR"
rm -f "$BIN_DIR/$LAUNCHER_NAME"
rm -f "$DESKTOP_DIR/$APP_ID.desktop"
# Old SVG location (pre-2026-04-21 rebrand) and current multi-size PNGs.
rm -f "$HICOLOR_DIR/scalable/apps/$APP_ID.svg"
for sz in 48 64 96 128 256 512; do
rm -f "$HICOLOR_DIR/${sz}x${sz}/apps/$APP_ID.png"
done
command -v update-desktop-database >/dev/null 2>&1 && \
update-desktop-database -q "$DESKTOP_DIR" || true
command -v gtk-update-icon-cache >/dev/null 2>&1 && \
gtk-update-icon-cache -q -t "$HICOLOR_DIR" || true
if [[ -f "$HYPR_CONF" ]]; then
# Only strip if both markers are present (closed block). An unclosed BEGIN
# would otherwise make awk eat everything to EOF, including user edits.
if grep -qxF "$MARK_BEGIN" "$HYPR_CONF" && ! grep -qxF "$MARK_END" "$HYPR_CONF"; then
echo "!! unclosed '$MARK_BEGIN' block in $HYPR_CONF — not touching it."
elif grep -qxF "$MARK_BEGIN" "$HYPR_CONF"; then
say "Stripping Hyprland windowrules block"
tmp="$(mktemp)"
awk -v b="$MARK_BEGIN" -v e="$MARK_END" '
$0 == b { skip = 1; next }
skip && $0 == e { skip = 0; next }
!skip { print }
' "$HYPR_CONF" > "$tmp"
mv "$tmp" "$HYPR_CONF"
fi
if command -v hyprctl >/dev/null 2>&1 && [[ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]]; then
hyprctl reload >/dev/null
fi
fi
echo "${DIM}Pacman packages left in place. Remove manually if desired.${RESET}"
echo "Done."