commit 749e102bd5eeafb232c233640358c0714f0fc412 Author: 28allday Date: Tue Apr 21 20:43:14 2026 +0100 Initial release: NO-CODER batch ProRes transcoder for Omarchy Native GTK4 + libadwaita app that wraps ffmpeg to batch-convert source video into editorial-ready Apple ProRes .mov. Targets Omarchy / Hyprland on Arch Linux specifically. Highlights: * Real ffmpeg encode (prores_ks → prores fallback) with live progress parsing, cancelable serial queue, disk-space pre-check, source-missing guard, output-collision (N) suffixes. * GPU decode auto-probe at install time — picks cuda → qsv → vaapi based on what actually initialises on the host. ProRes encoding stays on CPU (no vendor ships a GPU encoder); offloading the decode side cuts wall time 25-40% on H.264 / HEVC sources. * Theme-aware: tracks the active Omarchy theme on every launch by parsing colors.toml / ghostty.conf / alacritty.toml / kitty.conf in priority order. 34 stock + custom themes verified. * Pro camera support: .MXF (Canon XF / Sony XDCAM / Panasonic AVC-Intra) with proxy-directory pruning so dropping a Sony XAVC card maps masters in CLIP/ but skips the low-res duplicates in SUB/. * Multi-track audio preserved — 4 mono PCM streams from a Canon C300/C500 land in the output as 4 separate tracks. Optional 24-bit toggle. * Live encode-speed indicator with ffmpeg -progress parsing; ETA refines from real measured throughput rather than a fixed heuristic. * Hyprland-aware install — registers walker entry, six hicolor icon sizes, float+centre windowrule for class dev.nocoder.NoCoder. Distribution model: git clone + bash install.sh. The installer copies the source tree to ~/.local/share/nocoder/ so the clone is disposable. Updates are git pull + re-run install.sh. Documented at README.md. 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 0000000..33bf67d Binary files /dev/null and b/assets/logo.png differ 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 0000000..33bf67d Binary files /dev/null and b/packaging/dev.nocoder.NoCoder.png differ 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."