From 749e102bd5eeafb232c233640358c0714f0fc412 Mon Sep 17 00:00:00 2001 From: 28allday Date: Tue, 21 Apr 2026 20:43:14 +0100 Subject: [PATCH] Initial release: NO-CODER batch ProRes transcoder for Omarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 19 + README.md | 90 +++ assets/logo.png | Bin 0 -> 44728 bytes install.sh | 328 +++++++++++ nocoder/__init__.py | 2 + nocoder/app.py | 405 +++++++++++++ nocoder/data.py | 131 +++++ nocoder/encoder.py | 497 ++++++++++++++++ nocoder/footer.py | 374 ++++++++++++ nocoder/hwaccel.py | 108 ++++ nocoder/queue_pane.py | 637 +++++++++++++++++++++ nocoder/settings_pane.py | 588 +++++++++++++++++++ nocoder/window.py | 739 ++++++++++++++++++++++++ packaging/dev.nocoder.NoCoder.desktop | 13 + packaging/dev.nocoder.NoCoder.png | Bin 0 -> 44728 bytes run.py | 28 + style.css | 782 ++++++++++++++++++++++++++ uninstall.sh | 61 ++ 18 files changed, 4802 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 assets/logo.png create mode 100755 install.sh create mode 100644 nocoder/__init__.py create mode 100644 nocoder/app.py create mode 100644 nocoder/data.py create mode 100644 nocoder/encoder.py create mode 100644 nocoder/footer.py create mode 100644 nocoder/hwaccel.py create mode 100644 nocoder/queue_pane.py create mode 100644 nocoder/settings_pane.py create mode 100644 nocoder/window.py create mode 100644 packaging/dev.nocoder.NoCoder.desktop create mode 100644 packaging/dev.nocoder.NoCoder.png create mode 100755 run.py create mode 100644 style.css create mode 100755 uninstall.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de2745a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..962a05c --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# NO-CODER + +A native GTK4 + libadwaita batch transcoder for Omarchy. Drop video files (or whole camera cards) onto the window, choose a ProRes profile, hit Encode. Output is editorial-ready Apple ProRes `.mov` ready for DaVinci Resolve, Premiere, FCP, Avid. + +The brand is the **NO SIGNAL** circle — every other colour follows whichever Omarchy theme you have active. + +![drop zone with the NO SIGNAL logo](assets/logo.png) + +## Features + +- **Real ffmpeg encode** — `prores_ks` (with fallback to plain `prores`), live progress bar parsed from `-progress pipe:1`, cancelable, serial queue with disk-space pre-check. +- **GPU decode auto-probe** — installer tests `cuda` → `qsv` → `vaapi` and pins the working one to `~/.config/nocoder/config.json`. ProRes encoding stays on CPU (no vendor ships a GPU ProRes encoder), but offloading the *decode* side cuts wall time by 25-40% on H.264 / HEVC / AV1 sources. +- **Theme-aware** — palette tracks the active Omarchy theme on every launch (parses `colors.toml` / `ghostty.conf` / `alacritty.toml` / `kitty.conf` in priority order). 34 stock + custom themes verified. +- **Pro camera ready** — `.MXF` from Canon XF / Sony XDCAM / Panasonic AVC-Intra, with proxy-directory pruning so dropping a Sony XAVC card maps only the masters in `CLIP/` and not the low-res duplicates in `SUB/`. +- **Multi-track audio preserved** — Canon C300/C500 records 4 mono PCM streams; all four land in the output `.mov` as separate tracks. Optional 24-bit toggle for pro delivery. +- **Live encode-speed indicator** — footer shows real `1.5×` throughput from ffmpeg and refines the ETA from actual measured rate, not a fixed heuristic. +- **Hyprland-aware install** — registers a `.desktop` entry with the walker, installs the icon at six hicolor sizes, appends a windowrule that floats and centres the app at 1280×880. + +## Supported source formats + +`.mov` `.mp4` `.m4v` `.mkv` `.avi` `.mts` `.m2ts` `.webm` `.mpeg` `.mpg` `.3gp` `.3g2` `.mxf` + +**Not supported** (proprietary RAW; ffmpeg has no decoder without vendor SDKs): `.crm` (Canon Cinema RAW Light), `.braw` (Blackmagic), `.r3d` (RED), `.ari` (Arri). Pre-transcode those via Canon Cinema RAW Development / Blackmagic RAW Player / REDCINE-X / ARRI Meta Extract first, then bring the resulting MXF or MOV into NO-CODER. + +## Install + +Targets Arch / Omarchy specifically. + +```sh +git clone https://git.no-signal.uk/nosignal/nocoder.git +cd nocoder +bash install.sh +``` + +The installer: + +1. Verifies pacman is present, fails fast otherwise. +2. Installs missing pacman packages: `python python-gobject gtk4 libadwaita ffmpeg`. +3. Installs Inter and JetBrains Mono fonts to `~/.local/share/fonts/` (per-user, no sudo). +4. Probes GPU decode and pins the working backend to `~/.config/nocoder/config.json`. +5. Copies the source tree to `~/.local/share/nocoder/` so you can delete this clone afterward. +6. Writes a launcher to `~/.local/bin/nocoder`. +7. Drops the `.desktop` file and PNG icons into the right XDG locations. +8. Appends Hyprland windowrules (float, centre, 1280×880) inside a marked block in `~/.config/hypr/windows.conf`. +9. Restarts walker so the entry appears immediately. + +After install, **Super+Space → "no"** launches it. Or `nocoder` from a shell. + +## Updating + +```sh +cd nocoder +git pull +bash install.sh +``` + +Re-running the installer wipes and re-copies the live install dir — files removed upstream propagate cleanly. + +## Uninstall + +```sh +bash uninstall.sh +``` + +Removes the installed app tree, launcher, desktop entry, all six icon sizes, the Hyprland windowrules block, and the per-user config. Pacman packages and fonts are left in place (other apps may need them). + +## Hardware + +- **Required:** anything that runs Omarchy / Hyprland. +- **Recommended:** a GPU with ffmpeg-supported decode (NVIDIA NVDEC, Intel QSV, AMD VAAPI). The probe falls back to CPU decode on systems without; everything still works, just slower on camera-native sources. +- **No upper limit on cores** — `prores_ks` is well-parallelised. + +## Configuration + +`~/.config/nocoder/config.json` — currently just `{"hwaccel": "cuda" | "qsv" | "vaapi" | "none"}`. Edit by hand to override the auto-probed choice. + +## Known gaps + +- No persistence for last-used output folder / profile (resets to defaults each launch). +- "Reveal in Files" opens the output folder but doesn't *select* the specific file. +- Per-row remove button isn't keyboard-accessible (mouse-hover only, by design — keeps tab order clean). +- No live theme-change pickup — theme swaps apply on next launch, not immediately. + +## License + +Not yet specified. The app wraps `ffmpeg` and depends on GTK4 / libadwaita; check those licenses for the redistributable parts. + +## Credits + +Born as a rewrite of `prowrap-yad.sh` (a yad-based ProRes batch transcoder), rebranded NO-CODER to lean into the visual identity. The encoding logic from the original bash script is preserved verbatim. diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..33bf67d690bd2615324c9269da718d12eab31dae GIT binary patch literal 44728 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 literal 0 HcmV?d00001 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ae6a992 --- /dev/null +++ b/install.sh @@ -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 - </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 - </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" </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" </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 < 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 ` 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, + ) diff --git a/nocoder/data.py b/nocoder/data.py new file mode 100644 index 0000000..e4003d0 --- /dev/null +++ b/nocoder/data.py @@ -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 diff --git a/nocoder/encoder.py b/nocoder/encoder.py new file mode 100644 index 0000000..8aca2d0 --- /dev/null +++ b/nocoder/encoder.py @@ -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 -pix_fmt [-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:` 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)) diff --git a/nocoder/footer.py b/nocoder/footer.py new file mode 100644 index 0000000..1acd5fc --- /dev/null +++ b/nocoder/footer.py @@ -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'[{idx + 1}/{len(files)}] {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'● {done} done'] + if failed: + parts.append(f'● {failed} failed') + 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('"', """) + ) diff --git a/nocoder/hwaccel.py b/nocoder/hwaccel.py new file mode 100644 index 0000000..b90d08b --- /dev/null +++ b/nocoder/hwaccel.py @@ -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 diff --git a/nocoder/queue_pane.py b/nocoder/queue_pane.py new file mode 100644 index 0000000..3590859 --- /dev/null +++ b/nocoder/queue_pane.py @@ -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 browse files 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 .mov .mp4 .mkv .avi .mxf .mts' + ' 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 diff --git a/nocoder/settings_pane.py b/nocoder/settings_pane.py new file mode 100644 index 0000000..676c9e7 --- /dev/null +++ b/nocoder/settings_pane.py @@ -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_.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"(? 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("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 diff --git a/packaging/dev.nocoder.NoCoder.desktop b/packaging/dev.nocoder.NoCoder.desktop new file mode 100644 index 0000000..5e777ba --- /dev/null +++ b/packaging/dev.nocoder.NoCoder.desktop @@ -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; diff --git a/packaging/dev.nocoder.NoCoder.png b/packaging/dev.nocoder.NoCoder.png new file mode 100644 index 0000000000000000000000000000000000000000..33bf67d690bd2615324c9269da718d12eab31dae GIT binary patch literal 44728 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 literal 0 HcmV?d00001 diff --git a/run.py b/run.py new file mode 100755 index 0000000..0f570ce --- /dev/null +++ b/run.py @@ -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()) diff --git a/style.css b/style.css new file mode 100644 index 0000000..8321a7c --- /dev/null +++ b/style.css @@ -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; +} diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..f4518c7 --- /dev/null +++ b/uninstall.sh @@ -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."