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:
commit
749e102bd5
18 changed files with 4802 additions and 0 deletions
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal 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
90
README.md
Normal 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
BIN
assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
328
install.sh
Executable file
328
install.sh
Executable 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
2
nocoder/__init__.py
Normal 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
405
nocoder/app.py
Normal 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
131
nocoder/data.py
Normal 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
497
nocoder/encoder.py
Normal 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
374
nocoder/footer.py
Normal 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("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace('"', """)
|
||||||
|
)
|
||||||
108
nocoder/hwaccel.py
Normal file
108
nocoder/hwaccel.py
Normal 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
637
nocoder/queue_pane.py
Normal 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
588
nocoder/settings_pane.py
Normal 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
739
nocoder/window.py
Normal 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
|
||||||
13
packaging/dev.nocoder.NoCoder.desktop
Normal file
13
packaging/dev.nocoder.NoCoder.desktop
Normal 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;
|
||||||
BIN
packaging/dev.nocoder.NoCoder.png
Normal file
BIN
packaging/dev.nocoder.NoCoder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
28
run.py
Executable file
28
run.py
Executable 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
782
style.css
Normal 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
61
uninstall.sh
Executable 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."
|
||||||
Loading…
Add table
Reference in a new issue