A focused desktop video transcoder built with PySide6 and ffmpeg -- originally a minimal front-end replacing HandBrake, whose QSV path is dead on this box (see Root cause below). PySide6 GUI queue, one file at a time.
- H.265 (HEVC) / H.264 (AVC), CPU / Intel iGPU / AMD GPU processing
- Quality-first or target-file-size workflows, MP4 / MKV, 8-bit / 10-bit
- Audio: copy-through when compatible, AAC conversion, stereo downmix
- Batch queue with automatic interlace detection and live per-file edits
- An "Expert" section for exact rate-control values, tune, and deinterlace overrides -- Normal mode never needs it; it's there when you do
Forked from a sibling, more exposed-by-default build ("TITAN-i Transcoder") -- same ffmpeg engine and settings model, same everything below this section (shared history, unchanged). This fork's own difference is the UI layer: Normal mode surfaces every real output/media decision in plain language above a collapsed-by-default Expert section holding the remaining encoder-mechanics controls -- no capability lost, just organized around "Normal = intent, Expert = actual encoder mechanics" instead of exposing everything flat. See Controls below for the current, full breakdown.
Linux-first development build, not yet packaged for distribution --
launch.sh currently assumes a local Python virtualenv with PySide6
installed and a system ffmpeg/ffprobe on PATH, not a bundled
runtime. A self-contained build (bundled Python + ffmpeg, real app
icon, .desktop entry) is planned but not done yet.
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python3 main.py
(./launch.sh does the same thing, but currently points at this
machine's own venv path -- edit it, or use the three commands above
directly, until that's made portable.)
Not a Flatpak sandboxing issue. Debian trixie dropped libmfx1 (the legacy
Media SDK runtime needed by this Gen9.5 Comet Lake iGPU) and only packages
libmfx-gen1.2 (oneVPL, Gen12+ only — wrong generation for this hardware
regardless). ffmpeg's own build confirms the same gap: ffmpeg -version
shows --disable-libmfx --enable-libvpl, so hevc_qsv is equally dead here,
in ffmpeg or HandBrake, native or Flatpak. The hardware and driver are fine —
vainfo against the Intel node shows the iHD driver loading with H264 and
HEVC (8-bit and Main10) EncSlice entrypoints.
Note: HEVC on this hardware only exposes EncSlice (non-low-power), not
EncSliceLP — H264 has both. Never add -low_power 1 to the HEVC VAAPI
args; it isn't available for HEVC here and will fail. (tests/test_worker.py
has a regression test pinning this down.)
hevc_vaapi talks to the iHD VAAPI driver directly via the same
EncSliceLP-adjacent path QSV would have used, skipping Intel's MFX/oneVPL
dispatch layer entirely.
The two GPUs' PCI bus IDs do not match their render node numbers:
- Intel UHD 630: PCI
00:02.0→/dev/dri/renderD129 - AMD RX 6600 XT: PCI
03:00.0→/dev/dri/renderD128
worker.find_render_node() resolves this via /dev/dri/by-path/pci-*-render
symlinks + PCI vendor ID (0x8086 = Intel), never a hardcoded node number.
If GPU selection code is touched again, keep using that approach.
A real ~2-hour source file (ffprobe showed color_range/color_space/
color_transfer/color_primaries all unknown at the container level —
nothing overriding what the decoder infers from the bitstream itself as it
goes) failed a real VAAPI encode partway through, every time, at the exact
same timestamp:
[vf#0:0] Reconfiguring filter graph because video parameters changed to yuv420p(tv, bt709), 1920x1080, unspecified alph
Impossible to convert between the formats supported by the filter 'Parsed_scale_vaapi_2' and the filter 'auto_scale_1'
[vf#0:0] Error reinitializing filters!
What's actually happening: this specific file's encoded bitstream signals
slightly different color parameters partway through (no container-level
metadata around to keep that consistent), which is a real, valid thing for
ffmpeg's decoder to detect and triggers a genuine filter-graph
reconfiguration mid-job — not a bug in this app's own command line. The
crash is ffmpeg's response to that: its default behavior tries to
auto-insert a software scale filter (auto_scale_1, a filter this app
never asked for) to bridge what it thinks is a format mismatch, and that
auto-inserted filter can never actually work against a vaapi hardware
surface — plain software scale filters don't understand hardware frames at
all. The whole job dies on a source property that has nothing to do with
resolution, bit depth, or any setting actually exposed in this app's UI.
Fix: -noautoscale on the output, for hevc_vaapi jobs only
(worker.build_args). It disables ffmpeg's auto-insert entirely — this
app's own explicit scale_vaapi already does all the scaling that's
actually needed, so there was never anything for the auto-inserted filter
to usefully contribute in the first place. Confirmed against the real
file, not assumed: the exact same "Reconfiguring filter graph" line still
appears at the exact same timestamp (the underlying trigger is real and
unavoidable), but encoding now continues cleanly past it instead of
crashing, verified against a real output file all the way through. Scoped
to is_vaapi specifically, not applied to libx265 too — there's no
vaapi surface in that pipeline for this exact failure to happen to, so
there's nothing to fix there, and carrying an untested behavior change
into a path that was never broken isn't worth the risk. See
tests.TestBuildArgsVaapi.test_sets_noautoscale /
TestBuildArgsX265.test_no_noautoscale_for_cpu_encoder.
constants.py Static config: encoders, rate-control modes,
resolutions, quality tiers, audio bitrates, and
the app-identity constants (APP_NAME/APP_VERSION/
APP_ORGANIZATION -- see that constant block's own
comment for why APP_ORGANIZATION never reaches
QSettings/QStandardPaths).
session.py Cross-session queue/output-folder persistence --
a small JSON file (QStandardPaths.AppDataLocation),
deliberately not QSettings (see its own docstring).
help_content.py Loads help/index.json + help/*.md, plus plain-
text search and a hand-rolled markdown-subset
renderer (headings/paragraphs/bullets/bold only --
no third-party markdown dependency). No Qt here.
help_window.py HelpWindow: the non-modal, searchable built-in
Help window (search field + category/topic tree +
article viewer) -- see Help & About below.
help/ Help content: index.json (category/topic/keyword
structure) + one *.md file per topic.
about_dialogs.py AboutDialog/SystemInfoDialog/LicensesDialog --
see Help & About below.
presets.py Trimmed to just load_builtin_presets() -- this fork
has no Presets feature (removed entirely, see
Controls below); this loader survives only because
builtin_presets.json's entries are convenient,
realistic full-settings-dict fixtures for tests
that need one (TestSettingsSummary et al.), not
because the app itself reads or writes presets.
builtin_presets.json Test-fixture data only now (see presets.py above).
worker.py The engine: turns a settings dict into an ffmpeg
argv (build_args), and TranscodeQueue, which runs
jobs one at a time via QProcess. No preset concept
here, by design -- it only ever sees a fully-
resolved settings dict, never a preset name.
main.py PySide6 GUI entry point and MainWindow's own core:
settings<->control sync, theme-choice handlers,
module-level main(). The bulk of MainWindow's
behavior lives in the two mixins below -- main.py
itself doesn't build any widgets or drive the
queue directly anymore.
ui_builder.py _UiBuilderMixin: every tab's widget construction,
the left/right panel shells, the collapsible-group
helper, the command preview box.
queue_controller.py _QueueControllerMixin: queue add/probe/run, the
file/output pickers, and the TranscodeQueue signal
handlers.
queue_widget.py DropTreeWidget (drag-and-drop, multi-column queue)
plus the queue table's column constants.
theming.py Stylesheet loading/token substitution, system-accent
derivation, and the two QSS-gap event filters
(combo-popup background, focus-visible) — see
Theming below.
formatting.py Pure display-formatting helpers (codec/channel
friendly names, size/ETA strings, the fuzzy-tier
caption bucketing) — no Qt, no `self`, directly
unit-testable on their own.
themes.py Dark/Light color-token dicts (see Theming below);
style.qss is otherwise theme-agnostic.
style.qss Layout/structure for every widget, with $TOKEN
color placeholders substituted by theming.py's
_load_stylesheet() against themes.py.
assets/ SVG glyphs style.qss paints on top of Fusion's
native checkbox/spinbox subcontrols (see Known
gaps for why), one set per theme (_dark/_light
suffix).
tests/ unittest suite: test_worker.py, test_presets.py
(just the fixture loader now, see presets.py
above), test_session.py, test_help_content.py,
test_about_dialogs.py (each mirrors its own small,
Qt-widget-free module), and test_main.py — the
latter covers the whole assembled GUI (ui_builder.py/
queue_controller.py/queue_widget.py/theming.py/
formatting.py/help_window.py/about_dialogs.py's own
menu wiring all get exercised through it, via the
one real MainWindow instance, rather than one test
file each). ~535 tests total.
The GUI is a fixed-width (456px) settings inspector on the left, wrapped
in a QScrollArea (a vertical scrollbar appears only if Expert's content
ever exceeds the window height -- none under normal conditions), and a
flexible workspace on the right, laid out with a plain QHBoxLayout (not
a resizable QSplitter — window resizing goes entirely to the right
pane). Left pane: a QTabWidget (Video / Audio — see Controls
below for the current Normal/Expert breakdown of each). Right pane: the
queue, output folder, run controls, progress bar, a live stats line, and
the full log. Window geometry (and Expert's own expanded/collapsed state)
is remembered across launches via QSettings(APP_NAME, APP_NAME) (on
Linux: ~/.config/VeloCoder/VeloCoder.conf).
The unfinished queue (path + per-video settings, in order) and the output
folder also survive a restart — a separate mechanism from the QSettings
scalars above, since this is a structured, growing list rather than a
handful of preferences: see session.py for the small JSON file
(session.json, under QStandardPaths.AppDataLocation) and
queue_controller.py's _schedule_session_save/_restore_session for
when it's written (debounced, after every real queue/output-folder
change, and force-flushed on close) and read (once at startup, right
after _restore_window_state). Already-completed videos are never
persisted; a video that was still Converting…, Failed, or plain
Ready when the app closed comes back next launch as a fresh Ready
row — this app never attempts partial ffmpeg resume, so there's nothing
else to restore it to. A restored job's hardware selection is
sanitized through the same _effective_current_settings machinery any
other real job boundary uses, in case the machine's available GPUs
changed since the session was saved. A path that no longer resolves to a
real file (moved, deleted, or on a drive that isn't connected) is
silently dropped, with a one-line status notice if anything was actually
lost this way.
The settings dict that flows from the GUI into build_args():
{
"encoder": "hevc_vaapi" | "libx265",
"gpu_vendor": "intel" | "amd" | None, # only meaningful for hevc_vaapi -- see GPU device selection gotcha
"rc_mode": "ICQ" | "CQP" | "VBR" | "CRF" | "bitrate",
"quality_value": int, # quality units for ICQ/CQP/CRF, target size in MB for VBR/bitrate
"speed": str, # "1".."7" (vaapi compression_level) or an x265 preset name
"bit_depth": 8 | 10,
"width": int, "height": int,
"container": "mp4" | "mkv",
"tune": str, # an x265 tune name, or "None" to omit -tune; ignored for hevc_vaapi
"deinterlace": bool, # bwdif (x265) or deinterlace_vaapi (VAAPI), see Deinterlace below
"audio_track": int, # 0-based
"audio_copy_if_compatible": bool,
"audio_bitrate": str, # e.g. "160k", used only when transcoding audio
"audio_downmix_stereo": bool, # forces a stereo mixdown, only when the source has >2 channels
}The left pane is grouped into two tabs, by what kind of setting they are.
Landed on the current tab-bar look after several iterations (a plain
underline read as floating clickable text; a heavier filled-plus-outline
selected state read too heavy; an accent-colored selected border was
tried and dropped for the same reason section headings and the outer
tab-pane border are neutral elsewhere in this app — blue is reserved for
controls/progress/focus/Convert, not chrome): border-bottom: none on
every tab handles the seamless-with-content edge regardless of selected
state; the other three sides stay $BORDER for both states now, with
the selected tab distinguished by its $BG_PANEL fill and bold text
instead of a color change.
Both tabs follow the same rule: Normal mode surfaces every real output/media decision; an Expert section (collapsed by default, Video tab only — Audio has nothing left to put in one, see below) holds the remaining encoder-mechanics controls. Nothing in Expert is a second copy of a Normal control with a friendlier label — where a concept genuinely has both a plain-language and a precise form (Quality, notably), the Normal control and its Expert counterpart drive the exact same underlying value, never two independently-tracked ones.
Video tab — Normal
Encoding card
- Processing — which engine runs the encode: Automatic
(recommended — picks the fastest available option on this machine,
preferring Intel iGPU, then AMD GPU, then CPU) or an explicit CPU
(software) / Intel (iGPU) / AMD (GPU) choice (the latter two
both
hevc_vaapi, distinguished by thegpu_vendorsettings-dict key). The explicit choices are dynamic now, not a fixed list:worker. detect_hardware()probes/dev/dri/by-pathonce at startup, then runs a one-framehevc_vaapivalidation encode (worker.validate_hevc_encode, ~0.15s per GPU, using that vendor's own Quality rate-control mode) on each render node it finds. Intel/AMD only appear as buttons at all when that vendor's render node resolved and its validation encode worked. A render node alone isn't enough: with no VAAPI driver installed, or with a decode-only one (Fedora's ownlibva-intel-media-driverhas no encode on a UHD 630 — RPM Fusion nonfree'sintel-media-driverdoes), the node still exists but every hardware job would fail. A GPU that fails validation is logged with ffmpeg's error to the debug log and listed under "Unavailable Processing" in System Information — a machine with only one GPU vendor gets a two-way CPU/Intel or CPU/AMD row, and a machine with neither shows no Processing row at all (Automatic and a lone CPU choice would always mean the same thing, so there's nothing to ask). No NVIDIA option yet — NVENC support is planned as a separate, isolated pass once there's real NVIDIA hardware to validate it against, not bundled into Intel/AMD discovery.constants.ENCODERSis a list of(engine id, gpu_vendor, label)triples in this same CPU/Intel/AMD order, specifically so one ffmpeg codec (hevc_vaapi) can back two distinct menu entries;constants.encoder_profile_key()turns a resolved(encoder, gpu_vendor)pair back into the keyRC_MODES/RC_MODE_FRIENDLYare keyed by ("hevc_vaapi_intel","hevc_vaapi_amd","libx265", or"libx264"). Switching Processing carries the user's intent across the change rather than resetting it — an active Quality tier is translated to the equivalent tier on the new engine's own quality scale (ICQ/CQP/CRF aren't the same numbers), and an active File Size target is left untouched (a plain MB number needs no per-engine translation). - Codec — H.265 (HEVC,
libx265) or H.264 (AVC,libx264), the CPU engine's own second axis; disabled and forced to H.265 whenever Processing picks a hardware engine (noh264_vaapiwired up, hardware here is HEVC-only) — not hidden, since "H.265 (HEVC)" is still the real, correct answer for hardware, just no longer a choice. Switching Codec preserves the active Mode/Quality-tier/Target-Size the same way switching Processing does (main.py's_on_codec_changed) — a real, previously-reported bug had this silently reset to the default quality value on every H.265<->H.264 switch.main.py's_current_encoder_id()resolves Processing + Codec together into the one real ffmpeg encoder id everything downstream (RC_MODES,build_args, ...) actually keys off of. libx264 shares almost the entire settings surface libx265 exposes — CRF/bitrate rate control, 10-bit, the sameultrafast..placebopreset names (confirmed against this exact ffmpeg build) — except its own, larger Tune list (see Expert below) and no equivalent of libx265's own-x265-paramspsycho-visual tuning (meaningless to libx264, and unlike x265's historically conservative defaults, libx264's own upstream defaults are already well-regarded).
Quality card
- Mode — Quality or File Size, a plain-language 2-way choice
over the same
rc_mode_comboExpert's own Rate Control drives (below) — picks which of the next two rows is shown. - Quality (Mode = Quality) — Smaller File / Balanced /
Better Quality, three fixed points on the current encoder's own
quality scale (
constants.QUALITY_TIERS). Exact numeric control over the same value lives in Expert's Exact Quality (below) — both read/write the identicalquality_value, never two independently- tracked numbers. - Target Size (Mode = File Size) — a target output size in MB,
not a literal bitrate. The real
-b:vffmpeg gets is derived from this number and the specific file's actual duration insidebuild_args(worker.target_size_to_bitrate_kbps), after reserving an estimate for the audio track's own share. That reservation now correctly uses the source's real bitrate when Automatic is actually going to copy an already-compatible track through untouched (a previously-reported bug reserved the configured AAC bitrate even then, letting a copied, higher-bitrate track push the real output materially past the requested size —worker.probe_audio_bitrate_kbps, falling back to the configured AAC figure only when the source genuinely doesn't report one, e.g. common for MKV, not for MP4). A small caption under the field shows the resulting estimated kbps for whichever file is selected (or first in the queue). Setting the same target size across a multi-selected batch of differently-long files is a feature, not a bug — each file independently aims for that size using its own duration.
Format card
- Resolution — Keep Original / 1080p / 720p / 480p, fit within the
box keeping aspect, never upscales. Both scale filters carry
force_divisible_by=2— without it, a source whose aspect ratio doesn't exactly match the target box rounds one dimension to odd, which every pixel format this app uses (4:2:0) rejects outright. - File Format — MP4 or MKV.
-movflags +faststartis only added for MP4 (a mov/mp4-muxer-private option, silently ignored elsewhere). - Color Depth — 8-bit or 10-bit (
main/nv12vsmain10/p010lefor VAAPI;yuv420pvsyuv420p10lefor CPU). H.264 specifically: 8-bit offers the broadest playback compatibility of any option in this app; 10-bit H.264 needs compatible software/devices too, just less broadly required than H.265 does.
Video tab — Expert (collapsed by default)
Real encoder-mechanics controls, not a second, friendlier copy of anything already in Normal:
- Rate Control — the actual
rc_mode_combo, shown directly with its own real per-encoder labels (constants.RC_MODES): ICQ / CQP / VBR for Intel, CQP / VBR for AMD (AMD's VAAPI driver, Mesa radeonsi, rejects ICQ outright — confirmed via both a realvainfocapability listing and a real failed test encode), CRF / Target bitrate for CPU. This used to be a second Quality/File Size/Advanced button row here, functionally duplicating Normal's own Mode toggle with different labels — replaced with the real dropdown so Expert genuinely shows encoder mechanics instead of restating Normal. - Exact Quality — the precise numeric value (same
quality_valueNormal's Quality tier buttons set) via a slider, range following the specific mode (e.g. ICQ 1–51 vs CRF 0–51 aren't the same scale, so this re-ranges on every encoder/rc_mode change).setInvertedAppearance/setInvertedControlsflip the slider so left is worse/smaller, right is better/larger — every rc_mode's underlying scale runs the opposite way (a lower number means better quality) otherwise. A fuzzy tier caption underneath ("Movies & TV — general purpose", etc.) names what the current position is actually good for. - Encoding Speed — "Faster" to "Slower" for either engine
(
-compression_level1–7 for VAAPI, x265/x264's own preset ladderultrafast…placebofor CPU). VAAPI's own inverted-appearance direction isn't a guess: timed real encodes at compression_level 1/4/7 confirmed lower values are genuinely both slower and more size-efficient at a fixed quality target. - Tune (software only) — hidden for a hardware engine (
hevc_vaapihas no equivalent option). x265 (constants.X265_TUNES):animation,grain,psnr,ssim,fastdecode,zerolatency, orNone.filmis deliberately not offered for x265 — this exact libx265 build rejects it outright (confirmed by actually running it). x264 (constants.X264_TUNES) addsfilmandstillimageon top of that same list, both confirmed to work against this exact libx264 build. - Force Deinterlace — new files are sampled and this is set
automatically (see Queue pane below); this checkbox is a manual
override on top of that, for when detection gets a specific file
wrong. When on: VAAPI gets
deinterlace_vaapi=rate=frameafterhwuploadand beforescale_vaapi; CPU getsbwdif=mode=send_framebeforescale. Both explicitly pin single-rate output — bwdif's own default (send_field) silently doubles the frame rate, which isn't what a "just fix the interlacing" checkbox should do.
Audio tab — Normal only (no Audio Expert — every genuine audio setting the backend supports is already promoted here; an Expert section with nothing left to put in it would be worse than none)
- Track — narrowed to what the selected file(s) actually have
(
worker.parse_probe_output's ownaudio_track_count, already probed for the queue table's own subtitle text) rather than a fixed Track 1–4 regardless of source — picking a track that doesn't exist used to silently produce audio-less output instead of preventing the choice. With multiple files selected, only track indexes valid for every selected file are offered (the safe intersection). - Handling — Automatic (copies the track through untouched when
it's already
aac/ac3/eac3, otherwise transcodes to AAC) or Convert to AAC (always transcodes, even from a compatible codec). Split out from a previously-bundled "Automatic/Convert to Stereo" toggle that never actually exposed copy-vs-transcode as its own real choice — copy-vs-transcode and channel layout are independent decisions. - Channels — Keep Original or Stereo, split out the same
way. Only actually does anything when the source genuinely has more
than 2 channels (
worker.probe_audio_channels, only probed when this is set at all) — checking it on an already-stereo/mono source has no effect. A stream copy can't remix channels, so on a source that does need it, this forces a transcode even when Handling would otherwise have copied —-ac 2(libswresample's own remix), not a hand-writtenpanfilter, so it remixes correctly from whatever the source's real layout turns out to be. - AAC Bitrate — used whenever the track is actually transcoded (stays adjustable even under Automatic Handling, since a source the copy path can't handle — DTS, PCM, ... — still needs transcoding at whatever this is set to). A slider over the 5 real stops (96k/128k/ 160k/192k/256k, captioned Smallest file → Highest quality) — an index into the fixed list, not the kbps number itself, since the real values aren't evenly spaced.
Below the tabs, left side (VeloCoder specifically — see this fork's own note at the top of this file. The sibling TITAN-i Transcoder app still keeps Effective Command inline exactly as described in its own copy of this section.)
- Effective Command no longer has a visible panel in the main window at
all.
self.command_preview(worker.build_args(..., probe_audio=False, ...)under the hood — same function real jobs use, so it can never drift from what actually runs) still exists and still updates on every settings change; it's just never added to any layout. The only exposed entry point now is Edit → Copy FFmpeg Command, which reads the resolved argv list directly (self._last_preview_args) rather than this widget's own display text —_copy_command_to_clipboardneeds no visible preview to work at all.
Theme (System/Light/Dark) and hardware-acceleration status used to sit in a
permanent, qBittorrent-style footer strip at all times — reported live as
the most generic-desktop-utility-feeling part of an otherwise much
friendlier window, so this fork's own footer is gone entirely. Theme moved
into Edit → Settings… (Ctrl+,) and View → Theme, a small QDialog hosting the
exact same theme_combo widget, reparented in on open rather than
duplicated. Hardware status is silent during normal operation, including
when no hardware acceleration exists at all — Automatic Processing
already picks the best available engine on its own, and a machine with no
GPU falls back to CPU without comment (CPU is a completely ordinary
outcome, not something worth greeting a non-technical user with on
startup). Processing's own row already reflects this directly, hiding
itself entirely rather than offering CPU/Intel/AMD choices that don't
exist (see the Processing entry above) — there's no separate startup
notice on top of it.
Queue pane (right side)
-
The queue itself — a
DropTreeWidget(flatQTreeWidget, no actual hierarchy): a real multi-column grid with a header bar, not a single-line list. Four columns, not the technical File/Video/Duration/Audio/Size/ Result grid the underlying engine's own sibling app still uses — Video (the delegate-painted two-line card described next) / Duration / Size / Status (Readywhile queued, liveConverting… NN%while running, a size-change summary once done, or a shortFailed— the full reason stays in that cell's tooltip and the Log). The chosen output settings (encoder, quality, container, …) are deliberately not repeated here — they already live in, and edit live from, the right-hand settings panel for whichever row is selected (see "Selecting a row edits it live" below), so a second copy in the grid would just be the same information twice.The Video column is the one with a real custom painter (
queue_widget._VideoCellDelegate, installed viasetItemDelegateForColumn) rather than plainQTreeWidgetItemtext: a bold filename title over a muted subtitle line (codec + resolution + audio, e.g. "H.264 1920x1080 · AAC 5.1"), closer to a list of videos than a spreadsheet row.item.text(VIDEO_COL)is only ever the filename (set once, in_make_queue_row) — the subtitle lives on a second, distinct data role,queue_widget.VIDEO_SUBTITLE_ROLE(Qt.UserRole + 1), composed fresh by_refresh_video_cellfrom whichever of the source probe's video/audio labels and the interlace detector'sdeinterlaceflag have landed so far (see below) — three independent async results that can arrive in any order, none of which touch the title.STATUS_COLstill exists as a name (an alias, now forVIDEO_COLrather than the old six-column layout'sFILE_COL) purely so call sites reading/writing the row's job settings dict viaitem.data(STATUS_COL, Qt.UserRole)stay self-explanatory about why they're touching that particular column — genuinely a differentQt.UserRoleslot than the subtitle's, on the same column index, which is exactly the collisionVIDEO_SUBTITLE_ROLEexists to avoid._make_queue_rowfills in the Video title/Size synchronously (no I/O beyond astat()) and Status as"Ready"; the subtitle and Duration arrive from an asyncffprobemetadata probe once it lands (see below). Drag files in from a file manager to add them, or drag existing rows to reorder them (QAbstractItemView.InternalMove;DropTreeWidgettells the two apart by whether the drag carries URLs) — reordering relies on Qt's own row-move handling for a flat item-per-row model, the same mechanismQListWidgetused before this became a table, deliberately notQTableWidget, whose internal-move is cell-based rather than row-based and a known rough edge for exactly this kind of drag-reorder. Shows placeholder text when empty instead of a blank box. Every column but Result (see below), File included, is Interactive/user-resizable by dragging its header divider — initial widths are hand-tuned to this app's own real panel width rather than Qt's generic per-column default, sized to each column's actual content, but they're starting points, not fixed. File was originallyQHeaderView.Stretch(auto-claims leftover space) -- looked reasonable, but Stretch sections silently refuse to be drag-resized at all, with no visual indication why; switched to Interactive with an explicit initial width once that turned out to matter more in practice than the auto-fit convenience did. Result, the last column, is the one deliberate exception:header().setStretchLastSection(True)makes it claim whatever's left over on the right instead of leaving a bare gap between it and the panel's edge on a wide enough window. Trading away Result's own manual-resizability for that is an easy call, unlike it was for File — "612.3MB (71% smaller)"-style text doesn't vary anywhere near as much in length as a filename does. -
Source metadata probe — each added file immediately kicks off an async, non-blocking
ffprobe -show_entries format=duration:stream=...call (worker.build_probe_args/parse_probe_output, header-only, no decoding — fast even for a large file) that fills in the Video/Duration/ Audio columns once it lands. Runs alongside the existing interlace probe below rather than instead of it — both append to the same_detection_processeslist, so anything that already waited on that list (tests included) transparently waits for both. The Video cell's subtitle (see above — notitem.text(), that's just the filename) depends on three independent async results now: the probe's own video codec+resolution label, the probe's audio codec+channel label (folded in alongside the video one now, not a separate column), and the interlace detector'sdeinterlaceflag, appended as "(interlaced)" — they can land in any order, and_refresh_video_cellrecomposes the full subtitle from all three pieces of stashed raw state every time any one of them arrives, rather than concatenating piecemeal, so the result is correct regardless of arrival order. -
Interlace probe — kicks off the same moment as the source-metadata probe above and flips Deinterlace on the individual file on or off once it lands (
worker.build_idet_args/parse_idet_output, ~20s sample) — a real override in both directions, not a one-way ratchet, so a progressive file added after an interlaced one doesn't inherit a stale "on." Each row also picks up a status icon once a run starts (▶ encoding, ✓ done, ⚠ failed — a failed row's tooltip holds the failure reason, replacing the Video cell's usual path tooltip specifically, since that's exactly where the eye already goes to see why; the Status column's own text goes to a short "Failed" alongside it, live percentage while running). The icon lives on the Video cell itself (QTreeWidgetItemsupports an icon and text on the same column at once) rather than the Status column — a separate narrow column was tried first for just the icon, as unobtrusive as it could reasonably be made (24px, blank header), but an empty column with nothing in every row until a run actually starts still read as a stray gap rather than a deliberate part of the design (confirmed by feedback, not just a guess); Status itself only came later, once there was real per-row text worth a column of its own.STATUS_COLstill exists as a name in main.py — an alias, now forVIDEO_COL(see above) rather than the old six-column layout'sFILE_COL— purely so call sites that touch the icon/job-dict/failure-tooltip stay self-explanatory about why they're touching that column. The ▶/✓/⚠ glyphs are custom SVGs now (assets/status_play_*/status_done_*/status_warning_*), notstyle().standardIcon(...)— an Apple-design- language pass's "one icon family, one weight, throughout" moved these onto the same custom family Save/Delete already used, rather than the other way around: Save/Delete switched away fromstandardIcon()earlier specifically becauseSP_TrashIconhad a confirmed contrast bug (see Known gaps), so reverting them back to get a "native" icon set would have reintroduced that bug just to avoid drawing two more SVGs. Done is green, Warning is red — color reinforcing the shape, not carrying the meaning alone (the accessibility baseline this whole pass was checked against). -
Selecting a row edits it live — every control above is only the default baked into a file the moment it's added. Select one or more queued rows and the controls populate from the first one; change any control from there and it applies to every selected row immediately — no separate "Apply" step. (
_on_queue_selection_changedpopulates controls from a selection;_sync_settings_to_selected_queue_items, reached through_on_control_changed, pushes control changes back out._syncing_controls_from_selectionguards the loop between them — without it, merely selecting several differently-configured rows would silently homogenize them to the first one's settings before any control was even touched.) Remove/Clear (and reordering, and selection-edits) are all disabled for the duration of a run (_set_queue_editable) —TranscodeQueue.start()snapshots the job list once, so editing the visible queue after Start can't affect what's actually running; it can only make the list lie about it, or (for selection-edits specifically) overwrite a finished row's now-historical settings. Add Videos is deliberately exempt — see next. -
Adding files mid-run joins the run in progress — dropping a file in (or clicking Add Videos…) while a run is already going doesn't just sit there waiting for a second click of Convert;
add_filespushes it straight intoTranscodeQueue(queue.add_job) and it runs once its turn comes up. This needed its own patch path (TranscodeQueue. update_pending_job) rather than sharing a live reference with the visible row, becauseQTreeWidgetItem.setData/.data()round-trips a copy of the job dict, confirmed empirically — the running queue's snapshot, taken at add time, wouldn't otherwise see the async interlace-detection result land moments later. -
ODCS window model (shared with Keep via odcs-ui): a menu bar with every command and its shortcut (File, Edit, Queue, View, Help), and a toolbar with Add Videos… and Remove leading and Convert trailing as the one primary action (Stop / Open Folder appear just before it when relevant). The old "More actions" overflow button is gone. Clear Queue is in Edit; the Queue menu mirrors the Convert/Stop buttons' state. Delete-key (queue focused) and the right-click context menu still reach Remove directly either way. Clear Queue still asks for confirmation first (skipped entirely if the queue is already empty) — it can discard real per-file setup, so a destructive action like this always confirms first.
-
Save to (labelled Output folder in the sibling TITAN-i Transcoder app) — deliberately not the first thing in the window; it's a per-run detail, so it sits below the queue, near where it's used, not up with Quality/Format. Displayed as
~/Videos/transcodedrather than the fully resolved absolute path (formatting.display_path) whenever it's under the home directory — friendlier to read, and avoids spelling out the real username on a shared screen/screenshot;self.output_diritself is still the plain resolvedPatheverywhere it's actually used. The field is directly editable, not just settable via Change…'s browse dialog — typing a path doesn't check it exists (editingFinishedjust updatesself.output_dir), the same as a browsed-to path already didn't; both get created on demand (mkdir(parents=True, exist_ok=True)) right before they're actually needed, at Convert or Open. -
Open — opens the current output folder in the desktop file manager.
-
Run status, phase-dependent —
status_labelreads "Converting N of M — filename" while running (was "[N/M] Encoding filename"); underneath it, a plain-languageeta_label("About 8 min remaining", derived from the same whole-queue ETA estimate_queue_eta_secondsalready computed) is the prominent number, with the detailed telemetry — fps / bitrate / speed / this job's own ETA / whole-queue ETA, parsed from ffmpeg's-progressstream (TranscodeQueue._emit_stats) — staying exactly as technical as before instats_labelright below, just visually secondary. All ofprogress_bar/eta_label/stats_labelare hidden while idle (_apply_run_phase_visuals,queue_controller.py) instead of always showing an empty bar and dashes — idle just shows "Idle" and Convert. Preparing shows an indeterminate progress bar (no real fraction exists yet during file analysis); Converting/Paused show the full determinate telemetry. Checking Stop After Current Video (pause_after_check, a checkable action in the Queue menu) appends "— will stop after this video" to the status line, centralized through the_set_statushelper every status update goes through. -
Convert / Cancel, bottom-right of the panel, below status/progress/ ETA/stats rather than right under Save to — while converting, the progress area is the content, and Cancel is an action on that content, so it reads better trailing it. Right-anchored with Convert rightmost (
run_row.addStretch()before the buttons, Convert added last), borrowing macOS's own dialog/sheet button convention. Cancel is a plain secondary button now, not red/destructive-styled — cancelling an encode isn't destructive in the sense deleting something permanently is. Convert's own label is phase-dependent: "Convert N Videos" while idle, "Preparing…" (disabled — no cancel path exists yet for in-flight analysis) while videos are still being analyzed, "Converting…" (disabled) while a job is running, "Resume" while paused. Cancel itself is hidden entirely (not just disabled) whenever there's nothing to cancel, rather than sitting there grayed out. -
Finished-run summary —
_on_all_finishedno longer collapses straight back to "Idle": whenever at least one video actually completed during the run (even a partial one, cancelled or partly failed part-way through), it shows the count ("4 videos converted", ineta_label— "3 videos converted · 1 failed" if any jobs failed) and a whole-run size comparison ("12.4GB → 4.1GB · 67% smaller", instats_label, viaformatting.format_run_summary) alongside a new Open Folder button (reusing the existing_open_output_dir) in Cancel's old slot — Convert stays visible too, so re-running is still one click away. The heading itself (status_label) distinguishes why the run ended, not just whether anything completed — "✓ Conversion Complete" only when every job actually succeeded, "Conversion Stopped" if the user clicked Cancel at any point (even after some jobs had already finished), "Completed with Issues" if it ran to completion on its own but with some failures along the way, cancelled taking priority if somehow both happened in the same run. A run where nothing ever completed falls straight back to plain "Idle" instead of claiming "0 videos converted". The summary (both the heading and the two labels/Open Folder) persists until the queue's contents change (add/remove/clear/undo/redo —_refresh_idle_controls, which resetsstatus_labelback to "Idle" too, but only when a summary was actually showing — it leaves an ordinary idle status, like the one-time no-hardware notice, alone) or another conversion begins, whichever comes first. -
Log (raw ffmpeg stderr) has no permanent panel either, collapsed or otherwise — View → Show Conversion Log (Ctrl+L) opens it in its own small non-modal window, reparenting the real, already-live
log_viewwidget rather than duplicating it.queue_list's own stretch factor simply claims the space Log used to share space with it for.
Reachable from the Help menu (VeloCoder Help, About VeloCoder) or F1 (Help specifically) — no permanent ? buttons next to controls.
- Help (
help_window.py'sHelpWindow) is a non-modal, resizable window: a search field and category/topic tree on the left, an article viewer on the right. Only one instance ever exists — F1 or the menu action a second time raises/focuses the existing one rather than opening another (same lazy-singleton pattern_show_log_window/_open_settings_dialogalready use). Its own geometry persists across launches (QSettingskeyhelp_window_geometry), independent of the main window's own. It follows the app's Dark/Light/Match System theme choice — ordinary widget chrome (the search field, the tree) inherits the same app-level QSS every other window shares, but the article viewer is rich-text HTML with its own theme-derived colors baked in at render time, explicitly re-rendered on a theme change (refresh_theme()) since reloading the stylesheet alone doesn't retroactively touch already-set HTML content. - Content lives in
help/index.json(six categories, thirty-one topics, each with search keywords) + onehelp/<id>.mdfile per topic, loaded and searched byhelp_content.py— no Qt there at all, and no third-party markdown dependency (a small hand-rolled subset: headings, paragraphs, bullet lists,**bold**, deck text, callouts, and images, exactly what these articles actually use). Search matches title, keywords, then body text, in that priority order. Typing a search always keeps the article pane synchronized with the tree: the current topic stays selected if it's still a match, otherwise the first match is shown automatically, and a query with no matches shows a clean "No Help topics found" message rather than leaving whatever was on screen before. Clearing the search restores the full tree and the topic that was showing (or the Welcome topic, if the last thing on screen was that no-results message). - An article's first paragraph becomes a larger, muted "deck" line
under its title when (and only when) that whole paragraph is a
single
**bold**span — the article's own one-line summary, not a separate syntax.> **Label**/> body textlines become a rounded callout card (> **Recommended**,> **Tip**,> **Note**, ...), andembeds one of the five screenshots that illustrate the Quick Start flow, the Video tab's settings, a queue selection, the Audio tab, and Expert — each shipped as a dark/light pair (help/images/*-dark.png/*-light.png) and picked automatically for whichever theme is active. Screenshots establish visual context; the article's own text still carries the actual instruction, so a small control relabel or reflow doesn't make a screenshot the only thing conveying a step. - The topic tree's own look is scoped to Help alone (
style.qss'sQTreeWidget#helpTopicTreerules), not the app's generalQTreeWidgetstyling every other tree still uses: category headings are smaller, muted, bold, and shown in upper case; there's no separator line under every topic; and the selected/hovered row is a genuinely rounded highlight, painted by a small customQStyledItemDelegate(_RoundedSelectionDelegate) rather than QSS — Fusion's own native item-selection painting keeps a hard square corner regardless of anyborder-radiusQSS gives it. The tree runs atindentation()==0for the same reason: Qt paints its own accent-colored current-item indicator inside any nonzero per-depth indent column, via a native code path nothing short of removing the column itself can intercept; a topic still reads as nested under its category through a plain text-position offset instead. - Every article is short, plain-language, and consequence-first (what choosing an option actually does to your file, not how the encoder implements it) — Expert has its own category for the small minority of users who open that section, without surfacing implementation terms like VAAPI/render nodes/PCI IDs anywhere in ordinary Help.
- About VeloCoder (
about_dialogs.py'sAboutDialog) is a small, fixed-size modal: icon, name, version (from the one canonicalconstants.APP_VERSION), tagline, an FFmpeg credit, and a copyright line. The icon (main.py's_app_icon(), readingassets/app_icon.svg) is VeloCoder's own real app icon, not part of the light/dark themed icon family the rest of the UI draws from — a fixed, full-color mark that replaced an earlier generic play-glyph placeholder. The same icon is set asQApplication's ownsetWindowIcon()(main()), so it's also what the taskbar/alt-tab/window switcher show for every VeloCoder window, not just About's. Two buttons open further modals from there: System Information… (version/platform/FFmpeg version/the same cached hardware-backend snapshot Processing's own buttons use/current resolved theme name, with a Copy button — built only from app constants,platform.*, and display names, so there is no path, filename, or username to accidentally leak in the first place) and Licenses… (VeloCoder's own provisional license text, FFmpeg attribution, and third-party notices).
A "Theme:" combo in the status-bar footer: Dark / Light / Match System,
at the left edge, separate from the hardware-status text over on the right.
Originally a View menu, moved here after using both live — a whole menu bar
for one three-item setting was more chrome than the setting warranted.
QStatusBar genuinely has two different widget areas, not just one row
with a visual left/right split: addWidget (theme picker) puts something
on the left — also where a showMessage() temporary message would appear,
which specifically hides addWidget widgets for its duration, though
nothing in this app calls showMessage() today — and addPermanentWidget
(hardware status) puts something on the right, immune to that. The footer
strip's own setContentsMargins(8, 4, 8, 4) gives its content some
vertical breathing room — tried as a QSS padding rule on QStatusBar
first, which turned out not to reach addWidget/addPermanentWidget
content at all (confirmed by screenshot: zero visible difference); Qt's
own contents-margins property isn't mediated by the stylesheet the same
way and reliably does work. QStatusBar also carries its own
background-color: $BG_CONTROL and a border-top now (an Apple-design-
language pass's "chrome should read as a distinct layer from content"
principle — it had no background of its own before, so it just blended
into $BG_WINDOW instead of reading as its own strip). The choice
persists across launches (QSettings key
theme_choice) and, for Match System, keeps following the OS live via
QApplication.instance().styleHints().colorSchemeChanged (Qt 6.5+) — no
restart needed if the desktop's own theme flips while this app is open.
_resolve_theme(choice) turns "system" into a concrete "dark"/"light" at
the moment it's needed (Qt.ColorScheme.Light → light, anything else,
including Unknown, → dark); _validate_theme_choice guards whatever
QSettings hands back on startup, falling back to "dark" for None, an
empty string, a stale/typo'd value, or the wrong case ("Dark" ≠ "dark") —
a corrupted or hand-edited config file degrades to the default theme, not
a crash.
Colors and structure are deliberately in two separate files:
themes.py—DARK/LIGHTdicts, one color per token (BG_WINDOW,TEXT_PRIMARY, …) plus three*_ICONtokens per theme naming that theme's own checkmark/arrow SVG inassets/. Hand-tuned per theme, not a mechanical inversion of one another — a color that reads fine on a dark background often doesn't just because its lightness got flipped; hover directions flip too (lighter-on-hover for Dark, darker-on-hover for Light).ACCENT/ACCENT_HOVER/ACCENT_PRESSED/TEXT_ON_ACCENTare the one exception —themes.py's own values for these four are only ever a fallback now, not what actually ships.main.py's_system_accent_tokens()overrides all four at stylesheet-load time from the desktop's own accent color (QPalette.Accent, Qt 6.6+;QPalette.Highlighton older Qt) — an Apple-design-language pass's "one accent color, used consistently" read as "the user's accent," not a blue this app picked for them; confirmed on a real KDE session thatQPalette.Accentalready resolves to that session's actual configured color (#308cc6), not a generic Fusion default. Hover/pressed areQColor.lighter()/.darker()of that same color;TEXT_ON_ACCENTis picked from the accent's own perceived luminance (white text below a 0.5 threshold, near-black above) rather than assumed, since a user's chosen accent could be any hue or lightness, not just the blue this app shipped with before. Falls back to that previous fixed blue only if the palette role comes back invalid or pure black (a real desktop session's accent is never actually black, so that's a reliable "nothing resolved" signal). Because the resolved accent no longer varies by theme the way every other token still does,TEXT_ON_ACCENTdoesn't either now — it's a property of the accent, not of Dark vs. Light, which is the self-consistent outcome once the accent itself stopped being theme-specific.BG_ACCENT_DISABLED/TEXT_ACCENT_DISABLED(Start button, disabled) are deliberately left theme-fixed, not derived the same way — a muted echo of an arbitrary accent hue is a harder thing to get right by formula than the four tokens above, and wasn't what "follow the system accent" was actually asking for.CHECK_ICONrides along with this derivation too, and turned out to need to: the checkmark drawn inside a checkedQCheckBoxsits directly on the accent fill, exactly like button text does, so it needsTEXT_ON_ACCENT's own contrast decision, not whichever ofcheck_dark.svg/check_light.svghappened to match the old, theme-fixed accent. This was a real, live bug this change introduced and then caught in the same pass, not a hypothetical: once the accent stopped varying by theme,TEXT_ON_ACCENTcorrectly switched to the same color for both Dark and Light (it's a property of the accent now, not of the theme) — butCHECK_ICON's file selection was still keyed off theme name specifically, so Dark's checkbox ended up with a near-black check sitting on the exact same blue fill Start's white text was on. Confirmed by screenshot before and after, not assumed.style.qss— every other rule (layout, padding, radius, borders-as- structure), with$TOKENplaceholders standing in for colors._load_stylesheet()in main.py substitutes every$TOKENagainst the chosen theme's dict before the text ever reachessetStyleSheet()— tokens are substituted longest-name-first (sorted(palette, key=len, reverse=True)), because a couple of these are literal prefixes of others ($BG_CONTROL/$BG_CONTROL_HOVER/$BG_CONTROL_PRESSED, similarly for$ACCENT_*and$BORDER_*) — substituting the short one first would consume the start of the longer token's name too, corrupting it before its own turn came up.
Icons set from Python don't get this for free. $TOKEN substitution
only ever touches style.qss text, so it covers every icon reached via a
QSS image: url(...) rule (the checkbox indicator, spinbox arrows, the
combobox drop-down arrow) — but a QPushButton's icon is a QIcon object
built once in Python via .setIcon(...), which a later setStyleSheet()
call has no way to reach or refresh. Save As/Delete used
style().standardIcon(...) originally, which sidestepped this (Qt redraws
those from the palette automatically) but had its own problem instead —
see Known gaps. The fix for both: _themed_icon(name) builds a QIcon
from assets/{name}_{dark|light}.svg for whichever theme is current, and
_refresh_themed_icons() re-calls it for every such button, wired into
both _apply_theme and _on_system_theme_changed alongside the stylesheet
reload — so switching themes updates these the same moment it updates
everything QSS-driven, not on the next restart.
Switching themes swaps the color set only; nothing about layout, spacing, or which controls exist changes. Verified with real screenshots of both themes side by side, not just by reading the substitution logic — dark and light both keep the header bar / gridlines / alternating-row structure the queue table above relies on, just recolored.
Keyboard focus indicators. Buttons, checkboxes, the Speed/Quality
sliders, the queue table, and the tab bar had no focus indicator of their
own at all — confirmed by screenshot: Tab-ing to any of them showed no
visible change whatsoever, only the text fields (which already had a
:focus border-color rule) showed anything. Not a style preference —
someone navigating by keyboard alone needs to be able to see where focus
actually is, on every focusable control. Fixed with outline: 2px solid $ACCENT; outline-offset: 1px; rather than reusing the fields' border- color swap — outline draws outside a widget's own box without changing
its layout size, which matters here since several of these (the segmented
Rate Control buttons, sliders) already have exact-fit borders/radii a
border-width change would visibly disrupt. One real edge case this
turned up: an $ACCENT-colored ring is invisible on a control that's
already $ACCENT-filled — confirmed by screenshot, Tab-ing to the
already-selected Quality button (or to Start, always accent-filled while
enabled) showed no visible focus change even with the new rule in place,
while the same rule worked cleanly everywhere else. Fixed with a second,
more specific rule swapping just outline-color to $TEXT_ON_ACCENT for
#startButton and the segmented buttons' :checked:focus state — the
color this app already picks for exactly this contrast problem elsewhere
(button text sitting on an accent fill), not a new one invented just for
this.
That outline: 2px solid $ACCENT rule above was written against plain
:focus, which -- reported directly, confirmed by screenshot -- means a
checkbox clicked with the mouse gets the exact same ring Tab-ing to it
does. QSS has no :focus-visible equivalent (the CSS feature this is
really asking for: show the ring for keyboard navigation, not a pointer
click that already knows where it landed). QFocusEvent.reason() is the
same distinction :focus-visible's own heuristic is standing in for --
Qt.TabFocusReason/BacktabFocusReason for real keyboard navigation,
MouseFocusReason for a click, plus a handful of others
(ActiveWindowFocusReason, PopupFocusReason, ...) that aren't keyboard
navigation either. main.py's _FocusVisibleFilter -- a second
QApplication-level event filter alongside _ComboPopupBackgroundFilter
above, kept separate rather than merged since the two handle unrelated
concerns -- watches FocusIn/FocusOut app-wide and mirrors that
distinction onto a focusVisible dynamic property; every :focus
selector this applies to became [focusVisible="true"] instead
(QTabBar::tab:focus stayed a real :focus — a subcontrol isn't its own
QObject, so it can't carry a dynamic property the way a QWidget can).
Applied to every focusable widget app-wide rather than a specific type
list: a property no QSS rule references is a harmless no-op, so there's
nothing to gain scoping it down. One real bug this turned up before it
shipped: FocusIn/FocusOut also reach plain QWindow objects (a
top-level window gaining/losing OS-level focus, not any widget inside
it), which have no .style() — crashed the filter the first real run
against the actual X11 display until guarded with an isinstance(obj, QWidget) check, a case the offscreen test suite's synthetic
setFocus() calls never happened to exercise.
python3 run_tests_chunked.py
Plain stdlib unittest underneath, no extra install — but run through
run_tests_chunked.py rather than python3 -m unittest discover -s tests -v
directly, because a single long-lived process running the whole suite
accumulates Qt/PySide6 resources across the many MainWindow() instances
test_main.py constructs: an unchunked full run was confirmed to still not
be done after 4.5 hours (189 of 387 tests, RSS climbing throughout).
run_tests_chunked.py tears the process down every 20 tests instead, and
the same 387 tests pass in about 6 minutes. CI (.github/workflows/tests.yml)
uses it too. For running a single file, class, or test during development,
plain unittest is still fine and doesn't hit this — e.g.
python3 -m unittest tests.test_worker.TestTune -v (main.py's tests need
QT_QPA_PLATFORM=offscreen to run headless, but they set that themselves
before importing Qt, so it works with or without a real display). Most
test_worker.py tests check the argv build_args()
produces; several actually run ffmpeg against tiny synthetic clips (real
hardware encode included, skipped automatically if /dev/dri/by-path
doesn't exist) or drive a real TranscodeQueue end to end through a Qt
event loop — the whole point of this module is producing a command line
ffmpeg accepts and running real jobs correctly, and neither is something a
test that never calls ffmpeg/never starts a real QProcess can catch.
test_main.py covers GUI-level behavior that isn't build_args'
responsibility: startup ordering, the command preview's error handling,
audio accuracy and line grouping, the queue being locked during a run
(except Add Videos, which stays live), live mid-run queue append, Clear
Queue's confirmation, per-row status icons/result-size text, the queue
table's source-metadata probe, theming, and the Normal/Expert control
hierarchy (codec-switch state preservation, the Audio Track constraint,
Expert's collapse/expand behavior). One gotcha if you're adding to it:
QWidget.isVisible() reflects the whole ancestor chain, not just a
widget's own setVisible() calls — it's always False until the
top-level window has been .show()n at least once, even under the
offscreen platform.
A round of external review (two independent passes against this codebase)
turned up several real, confirmed bugs in the batch-transcoding path itself
— not just UI polish. Each was verified directly (a real repro against
real ffmpeg, or a real QProcess) before being fixed, not just patched on
the strength of the report alone:
- A missing/broken ffmpeg install left the whole queue stuck forever,
silently.
TranscodeQueueonly connectedQProcess.finished-- confirmed directly against a real nonexistent binary thatfinishedgenuinely never fires when a process fails to even start, onlyerrorOccurreddoes (a crash still reachesfinishedtoo, a crash is a way of finishing; onlyFailedToStartskips it entirely). No job_failed, no all_finished, nothing in the log, nothing to click -- just permanently "running." Now connectserrorOccurredtoo, filtered toFailedToStartspecifically so every other error kind still goes through the existingfinished-based path unchanged. - Two source files with the same stem (different folders) silently
overwrote each other's output, and a failed or stopped job could
destroy a pre-existing file at its output path.
-yused to write straight to the final name -- confirmed directly with the exact reported scenario (folderA/shot01.mov+folderB/shot01.mkv, both wantingshot01.mp4) that the second job's completed output silently replaced the first's. Fixed two ways together: output paths are now disambiguated within a run (and against whatever's already on disk) asname.ext,name (2).ext, ... before a job starts, matching how most file managers already resolve the same kind of collision; and ffmpeg now writes to a hidden temp name during the encode, renamed onto the real name only after a confirmed successful exit -- so a failed/stopped job's cleanup only ever deletes its own temp file, never anything that was already there. (The refuse-if-output-equals-input guard from before is unchanged and still checked first, against the un-disambiguated name specifically -- otherwise the disambiguation logic would have "solved" that case by quietly picking a different name instead of refusing outright, which is the wrong fix for a source-file-safety guard.) - A File Size target too small for the file's length silently produced
an arbitrary-quality encode instead of erroring.
target_size_to_ bitrate_kbpsreturning 0 flowed straight into-b:v 0k-- confirmed directly against real ffmpeg/libx265 that this doesn't error, it makes x265 silently fall back to its own default CRF (28.0), with no actual relationship to the size that was requested.build_argsnow raises instead, which the two real callers (the queue, the live command preview) already had exception handling for; the size-estimate label shows the same message before the user ever gets that far. - An auto-detected deinterlace race, in both directions. Adding a file
and clicking Start immediately could begin encoding before the ~20s
interlace sample landed, using whichever deinterlace value the file
started with -- Start now refuses (with a status message) while any
sample is still in flight. Separately, manually toggling Deinterlace for
an already-queued, selected file could get silently reverted when that
file's detection result landed moments later -- a new per-job
deinterlace_user_setflag, stamped only by a genuine user edit to an already-queued item (not by selecting a different item, and not by the detector's own result), makes a manual override stick. - Downmix to stereo didn't check the source's actual channel count. Documented under Controls above -- forced an unnecessary transcode on an already-stereo source, and would have upmixed a mono one, the opposite of what "downmix" means.
- Dragging to reorder the queue wasn't actually blocked during a run,
despite Remove/Clear already being locked for exactly the same reason
(none of the three can affect a job already running or finished without
the visible list lying about what's actually executing).
DropTreeWidgetnow refuses an internal-move drop while a run is in progress, while still accepting external file drops the same as before. - The Copy button under Effective Command wasn't reliably paste-safe.
It copied the display text -- grouped onto several lines, plain-space-
joined with no shell quoting -- so a path containing a space
(
/media/My Video.mov) pasted as two separate shell arguments instead of one. It now copiesshlex.join()over the real argv this preview was actually built from instead, confirmed to round-trip exactly throughshlex.split(). - Selecting an audio track that doesn't exist on the source silently
produced video-only output.
build_argsalready handled this correctly (skips mapping a stream that isn't there rather than failing the whole job) -- the gap was that nothing told the user their output would have no audio until they noticed on playback. The real queue now logs a note when this happens; the command-building logic itself is unchanged.
-
A checkable
QGroupBoxused as a collapse toggle (_make_collapsible_group-- both Effective Command and Log use it) needs its size policy, not just its content's visibility, toggled on collapse: a hidden child alone still left the group claiming its full stretch-factor share of the layout, which looked like a large empty box where the Log used to be -- confirmed by screenshot before the size-policy fix went in. If a third collapsible section gets added later and looks like it's not actually shrinking, this is almost certainly why. -
QWidget.isVisible()is alwaysFalseuntil the top-level window has had.show()called on it, regardless of the widget's ownsetVisible()state -- it reflects the whole ancestor chain, not just one widget. Tests that assert a control is visible needwindow.show()first (tests asserting it's hidden don't strictly need it, but the whole suite's existing convention is to call it anyway rather than have some tests rely on the distinction). Has bitten real tests more than once in this file's own history -- recorded here so the next one doesn't have to re-discover it. -
QSettingsround-trips a Pythonboolthrough its on-disk store as the literal string"true"/"false"(confirmed on this Linux/INI backend) -- a plainif value:truthiness check on a restored value is a bug, since the string"false"is itself truthy._restore_window_statecomparesstr(value) != "false"for exactly this reason (window_geometry predates this and gets away with it becauserestoreGeometrytakes the raw QByteArray directly, never a bool). -
style.qssis not valid QSS on its own — every color, plus the checkmark/arrow SVG paths ($CHECK_ICON,$ARROW_UP_ICON,$ARROW_DOWN_ICON), is a literal$TOKENplaceholder that only becomes real QSS once_load_stylesheet()in main.py substitutes it against athemes.pypalette (see Theming above). Loadingstyle.qssany other way (a quick screenshot/test harness, say) leaves those tokens in place, which Qt's QSS parser rejects outright (Could not parse application stylesheet) rather than just failing to find the image — confirmed by hitting exactly that while testing this. Go through_load_stylesheet(), don'tsetStyleSheet(open("style.qss").read())directly. Relatedly:tests.TestStylesheetLoading.test_every_token_gets_substitutedasserts no bare$survives a real load, which is why style.qss's own header comment has to describe this mechanism in prose without ever writing a literal$WORD— the substitution is a blind, globalstr.replaceacross the whole file, comments included. -
Touching a subcontrol in QSS at all (
::indicator,::up-button, …) replaces Fusion's entire native paint for it, not just the property you set — there's no partial opt-in. Three subcontrols hit this in practice, all fixed the same way (a themed background/border + an SVG fromassets/for the glyph, one file per theme since a$TOKENcan supply a path but not recolor an already-rasterized image): a checkedQCheckBoxrendered as a flat colored square with no checkmark;QSpinBox's up/down buttons rendered close to invisible (Fusion's default arrow color, un-adjusted for either theme since this app has noQPaletteof its own);QComboBox's drop-down button was originally left alone specifically to avoid this (see git history) — the native Fusion button it kept was a bigger problem once everything around it was themed, standing out as generic against a fully flat/themed app, so it got the same background+SVG treatment after all. All three confirmed by screenshot before and after, not assumed from the token values alone. A data-URIimage: url(data:image/svg+xml;...)was tried first instead of a real file for the checkmark — Qt's QSS parser can't reliably handle one inline (Could not parse application stylesheet); a real file is the only approach confirmed to work here. -
QGroupBox'smargin-tophas to comfortably clear the title text's own rendered height, not just roughly match it -- at 14px (this app's original value) the title sat straddling the border line instead of above it, the native/default GroupBox look where a rounded corner visibly cuts through the title text. Reads as dated once the rest of the app is flat and themed (confirmed by screenshot, not assumed); 22px clears it with room to spare. If a group's title ever looks like it's touching or overlapping the box border again, this is almost certainly why -- it's font-size-dependent, so a larger title font later would need more than 22px, not the same value assumed to still be enough. A related mistake on the very first pass at this fix: nudging::title'stopnegative to tighten the gap between the (now-cleared) title and the border below it seemed reasonable, but a negativetopoffset moves the title above the QGroupBox's own bounding box, not just up within the margin area already reserved for it -- it got clipped by whatever's above the group in the layout instead (confirmed by screenshot).topshould stay0(or positive) oncemargin-topis already sized correctly; there's no need to reach for a negative one at all. Left alignment had a similar rough edge --left: 12pxplus this rule's ownpadding: 0 6pxput the title a good ~18px in from the box's actual left edge, more than the 6pxpaddingalone would suggest and more than every other field/label in this app indents by;left: 0(padding still supplies the 6px) fixes it. -
The "no
QPaletteof its own" issue above isn't just a QSS-subcontrol thing —QPushButton(style().standardIcon(...), ...)(Save As/Delete's icons, until this was hit) draws from the same un-set palette, andSP_TrashIconspecifically rendered with poor contrast against a Light-theme button (confirmed by screenshot;SP_DialogSaveButtonlooked fine, so this isn't uniform across every standard icon). Fixed by replacing both with real theme-aware SVGs instead, same as everything else inassets/— see Theming's_themed_icon/_refresh_themed_iconsfor why aQIconbuilt once in Python needs its own explicit refresh hook that a$TOKEN-driven QSS icon doesn't. -
Any plain
QWidgetwith no background rule of its own used to fall through to a top-levelQWidget { background-color: $BG_WINDOW; }rule -- painting $BG_WINDOW behind it regardless of what it was actually sitting on. First noticed onQLabel/QCheckBox/QSliderspecifically (every label and the Deinterlace checkbox sitting in a visibly mismatched grey box against aQGroupBox's$BG_PANEL), fixed there, then noticed again around Effective Command's Copy button -- the bareQWidget()holding the command text box + Copy button is exactly the same class of bug, just a layout-only container this time instead of a visible control. Two confirmed instances of the same root cause was the signal to fix the actual cause instead of patching each widget type as it turned up: the top-level rule isbackground-color: transparentnow, and only the specific widgets that truly need an opaque background of their own (QMainWindow,QGroupBox, form fields,QPushButton,QTreeWidget, ...) carry an explicit rule, which still wins over the default regardless of what it is. Dark'sBG_WINDOW/BG_PANELare close enough (#1a1d23/#21252c) that none of this was ever visible there; Light's aren't (#eef0f3/#ffffff), which is what actually exposed a bug latent since before Light theme existed — confirmed by screenshot each time, not assumed from the token values. Third instance (a fourth follows below), and the one that finally explains why transparent-by-default isn't unconditionally safe: everyQComboBoxdropdown rendered with a solid black bar top and bottom of the list. A combobox's popup isn't a nested child widget the way everything else in this app is -- Qt wraps the list (QComboBox QAbstractItemView, already styled) in its ownQFrame, and thatQFrameis the popup's own top-level window (confirmed directly:view.window()is the same object asview.parentWidget()). Transparent is exactly the right default for a nested child -- show whatever's already opaquely painted behind it -- but wrong for a genuinely top-level window, which has nothing behind it to show through to; Qt/the platform renders that as black rather than as invisible. First fixed with an explicitQFrame { background-color: $BG_PANEL; border: none; }-- exceptQLabelis also aQFramesubclass (confirmed viaissubclass(), a fact easy to not know), so without an explicitQLabel { background-color: transparent; }override afterward (more specific than the bareQFramerule, so it wins regardless of which one appears first in the file), that same fix would have silently reintroduced the exact grey-label bug two entries up. Caught before it shipped, not after, by checking the class hierarchy directly instead of assumingQFrameonly meant "the combobox popup thing." Couldn't be confirmed by screenshot the usual way either -- theoffscreenQPA platform this test suite runs under doesn't do real window compositing, so a popup grabbed under it never reproduces the black bars regardless of whether the fix is correct -- so this shipped on the strength of theview.window() is view.parentWidget()structural check alone, without a real render to confirm it.That structural reasoning was sound and the
QFramerule is still in style.qss, but a later real screen capture (QScreen.grabWindow()/spectacleagainst the actual X11 display, notQWidget.grab()-- confirmed separately that.grab()reads a widget's own paint buffer and misses real compositor output, making it useless for verifying this specific class of bug) proved theQFramerule alone doesn't actually reach this popup: the bars were still there. The real cause turned up inspecting the popup frame directly --autoFillBackgroundisFalseandframeShapeisNoFrame, so nothing paints its background by ordinaryQWidgetmeans, and for reasons that didn't surface from the widget's own properties, the app-wide QSS cascade doesn't repaint it the way it does theQAbstractItemViewnested inside it (confirmed that one's background does apply correctly). What does work, also confirmed via real capture: a stylesheet assigned directly on the frame instance.main.py's_ComboPopupBackgroundFilter-- aQApplication-level event filter -- does exactly that atShowtime, matched viametaObject().className() == "QComboBoxPrivateContainer"rather thanisinstance/type().__name__: PySide6 has no Python binding for this private Qt class, so its Python-visible type reports as the nearest exposed base (QFrame) -- indistinguishable that way from every otherQFramein the app, wheremetaObject().className()still reports Qt's real C++ class name regardless of Python bindings.The same real capture surfaced a second, separate bug riding along on the same popups:
QComboBox[modified="true"]'s italic/$ACCENT-colored styling (the "you've changed something since loading this preset" indicator, see below) was bleeding into that combo's own popup list items too -- every preset name shown italic and accent-colored, not just the closed combo's own text. Not a cascade problem, and not fixed by anything targeting the view or the frame -- confirmed by resetting font/color directly on both, immediately and deferred by an event-loop tick, with zero effect. What actually stopped it: temporarily clearing themodifiedproperty on the combo box itself while its popup is open (restored onHide, so the closed combo still shows its own indicator correctly afterward). That only makes sense if the popup's item delegate paints using the owning combo's own currently-matched QSS state directly rather than anything inherited or copied onto the popup widgets -- confirmed indirectly, by process of elimination, rather than by reading Qt's own source for it._ComboPopupBackgroundFilterdoes both fixes together, keyed off the sameShow/Hidepair. Fourth instance, same root cause again:QMessageBox(Clear Queue's confirmation, Delete Preset's,Save As…'s Overwrite? prompt) is also a genuinely top-level window (QMessageBox→QDialog→QWidget, confirmed viaissubclass()— never a nested child the way the rest of this app's plainQWidgets are), reported directly with a solid black dialog body, same as the combobox popup and not reproducible underoffscreenfor the same reason.QInputDialog(Save Preset) andQFileDialog(Add Files…/Change…, though the latter may use the native OS picker instead of Qt's own rendering) areQDialogsubclasses too, confirmed the same way — fixed by targetingQDialogitself rather thanQMessageBoxalone, after first confirming (sameissubclass()check that caught theQLabel/QFrameoverlap above) that nothing already styled elsewhere in this file is secretly aQDialogtoo. -
Deinterlace auto-detect samples ~20s per file, not the whole thing — a file that's only partially interlaced (spliced from multiple sources) can be misjudged depending on which part gets sampled. Also: the sample is a real decode-only ffmpeg pass per file, so it costs some CPU even for files that turn out not to need it (async/non-blocking, so it doesn't freeze the UI, but it's not free). Every added file now also kicks off a second, separate
ffprobeprocess for the queue table's source-property columns (see Queue pane above) — header-only and fast, but it's still a second subprocess per file, on top of the interlace sample. Neither has a concurrency cap — dropping a large batch (a season of episodes) launches that many of each at once. Fine for a handful of files; a small queue/ semaphore would be worth adding before this gets used on 30+ files at a time. -
_preview_duration/_preview_audio_codec/_preview_audio_channels(the live command-preview and size-estimate probes,_update_command_ preview/_update_size_estimate_label) run real, synchronousffprobecalls directly on the GUI thread — cached per file so it only actually shells out once, but that first call still blocks the whole window (Qt's own 30s subprocess timeout is the upper bound) if it lands on a slow network mount or a file ffprobe struggles with. A probe failure no longer crashes the app (see Reliability fixes above), but a slow one still freezes it for however long it takes. Making these genuinely async (QProcess-based, matching the interlace/source-property probes above) would fix this properly; not done here since it's a larger structural change than the correctness fixes in this pass.Testing note if you touch this:
ffmpeg -vf idetitself false-positives on baretestsrc2test patterns (confirmed: 100% TFF on a genuinely progressive synthetic clip) — its high-frequency edges apparently read as combing to idet's heuristic.tests/test_main.py's_make_progressive_clipworks around this with a mild blur; don't swap in a baretestsrc2source for a "confirmed progressive" fixture without re-checking it againstidetfirst. -
-compression_level 1not A/B'd against remembered QSV output quality — try the range (1–7, lower = slower/better) if output doesn't match expectations. -
Intel/AMD's Smaller/Better Quality-tier ICQ/CQP values (16/36,
constants.QUALITY_TIERS) are this app's own estimate by analogy to CPU's real x265 community reference points, not independently A/B'd against real output either. -
No subtitle passthrough (explicitly
-sn'd — originally to avoid an MP4-incompatible subtitle codec failing the mux; MKV output removes that specific risk but nothing maps subtitle streams on either container yet) and no foreign-audio-search/burn-in — the old "Stuff Tuned" HandBrake preset had both, neither is replicated. -
No batch folder-watch.
-
Drag-to-reorder the queue (
QAbstractItemView.InternalMove) can't be driven through an automated headless test the way everything else in this app is — Qt's internal-move drag-drop goes through a real nativeQDrag/platform drag session, which theoffscreenQPA platform this suite runs under doesn't implement; syntheticQTestmouse press/move/ release events don't trigger it (confirmed by trying). Structurally this should be sound — a flatQTreeWidget's row-move is the same shape asQListWidget's (one item per row; deliberately notQTableWidget's cell-based model, see Queue pane above), and reordering worked the same way before this became a table — but it's the one piece of this feature that's only had a real interactive check, not an automated one. -
Error recovery is per-job, not per-failure-class: a bad job (an already-existing output name,
probe_duration/ffprobefailing, ffmpeg itself missing -- see Reliability fixes above, this specific case used to hang the queue rather than fail cleanly, fixed now) is caught and reported viajob_failed, and the queue moves on to the next file — but there's no retry, and a systemic problem (ffmpeg missing, a full disk) will still fail every remaining job in the queue one at a time rather than detecting the pattern and aborting the batch early.





