Skip to content
v0.7.0 - 2026-10-20

v0.7.0 - 2026-10-20

Release on GitHub

Added

  • The media pane has transport controls and reports its state. Play/Pause, a seek slider with elapsed/total time, and Loop sit under the media surface. Pause freezes the media clock and pauses the audio generator (a video-only pane offsets its clock on resume); a slider seek uses the backend’s acknowledged seek and resets the timeline - frames, queued audio and the clock epoch - before the new position plays, stopping the audio generator rather than trying to clear a buffer that is still active; Loop restarts a finished file from zero and is saved with the tile, while Play on a finished non-looping file also restarts. paneStatus now answers media panes too through a generic PaneBody.status_document() hook - state, file, position, duration, paused, loop and volume - while terminals keep their process/grid document and panes with no status remain listed as null.
  • Audio plays through the same Rust backend. Every audio format in the media table - Ogg Vorbis/Opus, MP3, WAV, FLAC, M4A/AAC - now decodes to PCM in gpty-media and is streamed into an AudioStreamGenerator at 48 kHz stereo; the native Ogg/MP3/WAV loaders are gone, so video and audio share one decoder path. Chunks are fed while the generator has room with a bounded local lead (an unconsumed backend queue would stall the worker, which also decodes video), the generator starts on the first real chunk so its playback position and the media clock share an origin, and video presentation follows the audio clock when audio is running (monotonic clock otherwise). Volume (dB) and mute are pane settings saved with the tile. An 8-bit mono WAV also exposed and fixed a real gap: pcm_u8 is now in the component set, and a layout-less PCM frame is normalized before resampling instead of panicking inside the binding.
  • The media pane renders Rust-decoded video. The pane’s video surface is now an ImageTexture fed by the pinned FFmpeg backend rather than Godot’s native VideoStreamPlayer, so .mp4, .m4v, .mov, .mkv, .webm, .avi, .ts and .m2ts join .ogv (which moved to the same backend - one video path, no fallback). Frames are drained only while a four-frame PTS-ordered backlog has room, and presented against a monotonic clock, so a decoder that runs faster than real time does not play the clip several times too fast; a stale frame is dropped only when a newer one is waiting. The surface keeps the clip’s aspect ratio with letterbox/pillarbox fit, and a backend refusal clears the pane’s file like any other unreadable path. Audio for these files is covered by the next entry.
  • Release bundles carry the pinned FFmpeg runtime. The GDExtension now has an $ORIGIN/@loader_path runpath, and a single scripts/bundle-media helper places the matching libav*/libsw*/DLLs beside it for Linux, Windows and macOS - including a universal (lipo’d, ad-hoc signed) macOS set and injection into the exported .app. FFmpeg’s LGPLv3/LGPLv2.1 texts and the component list travel in licenses/, the AUR recipe installs them under /usr/lib/gpty and /usr/share/licenses/gpty, and the Linux packer refuses a bundle whose libraries do not resolve. A locally extracted Linux bundle starts with LD_LIBRARY_PATH unset.
  • A Rust media backend demuxes and decodes the wider format matrix. A new gpty-media crate links a pinned, minimal FFmpeg build (9.0.2, LGPLv3, dynamically linked, no network protocols, only the components in components.txt) and exposes one session per file: demuxing plus H.264/HEVC/VP8/VP9/AV1/Theora video decoded to packed RGBA8, and MP3/FLAC/Vorbis/Opus/AAC/ALAC/AC-3/PCM audio resampled to interleaved f32 at the caller’s rate and channel count. One OS thread per session reads packets and fills bounded queues; the pane polls, a full queue blocks only the decoder, and a seek is acknowledged by the worker so pre-target frames are dropped rather than delivered. The GptyMedia GDExtension node is the JSON + packed-array edge, and the path gate is applied again in Rust (absolute local regular file; no FIFO, device, URL or project scheme). The pane’s video and audio paths both consume this backend.
  • A media pane plays local audio and video. An eighth pane type, media, opens an absolute local file and plays it through the pinned FFmpeg backend, with the file name and playback state shown in the pane. The path is validated at use by the closed MediaFiles table (absolute, regular file, known extension) and never reaches ResourceLoader, so a file-supplied layout or profile cannot smuggle a .gd or .tscn in behind a media extension; remote URLs and project schemes are refused with an in-pane explanation. The file tree’s in-app route now covers media too (supported files ask the workspace for a media pane, and reopening the same file focuses the pane already playing it), and a media tile restores its path. Transport controls and paneStatus transport state sit in the same pane, and the demux/decode matrix covers the wider container and codec set.

Fixed

  • A refused second gPTY window exits instead of waiting on a question it cannot ask. The startup-refusal path ran the normal quit walk, which asks every pane before closing; on Windows a live shell is conservatively “possibly busy” (foreground_job is null), so a headless second instance raised a confirmation nothing could answer and never exited. A refused instance never served a workspace, so it now says why and quits directly; a normal shutdown request still asks.

  • The media pane keeps the frames that are due. Polling drained eight decoded frames per tick and then kept only the newest four, so a clip the decoder finishes in milliseconds lost frames that were already due: the picture stayed on the first one while audio advanced. The drain now stops at a four-frame backlog, and a stream whose timeline does not begin at zero starts its clock on the first pending frame’s own PTS instead of holding the clip in the future for the length of the offset. A six-frame, 2 fps fixture pins the presented PTS sequence.

  • Seeking or restarting a media file no longer fights the audio player. The reset called AudioStreamPlayer.clear_buffer() while playback was still active, which Godot refuses (the engine logged Condition "active" is true), so a seek or loop restart did not empty the generator. It now stops the player and drops the generator reference - the next play() creates a fresh, empty generator - and a seek while paused keeps the paused position.

  • The link graph keys its edges without NUL. Godot strings do not hold NUL: "\u0000" decodes to U+FFFD and logs an engine error, so the separator-based pair key was not the identity it claimed. Counts live in a nested map keyed by the two endpoints, and the hit-test tie-break compares the endpoints directly.

  • The CLI no longer builds a worker pool it has no work for. gpty now runs on a current-thread tokio runtime: every command is one request in a process that exits as soon as it has an answer, so the multi-thread runtime’s workers bought no parallelism - they were only concurrency around process::exit, the plausible shape behind a one-off Windows STATUS_HEAP_CORRUPTION (0xC0000374) on an inject whose CLI printed nothing. It also drops the worker-pool startup from every invocation: 300 gpty version runs on Linux went from 1.04 s to 0.60 s. The Windows smoke now runs 100 throwaway CLI processes (≈ 200 connections - each process opens the version probe and then its command) right after the pane-status checks as the teardown stressor, and a nonzero exit fails there with its hex status instead of being retried.

  • Closing a pane, a workspace or the app asks before stopping a command that is still running. A terminal now reports whether a process other than its shell owns the PTY - exact on Unix, where the foreground process group changes only for a command that actually took the terminal (a background cmd & never counts), and conservatively on Windows/ConPTY, which has no equivalent and treats a live shell as possibly busy. Every destruction path walks the same two-phase question: one dialog for the action’s own hazard, naming every busy pane and raised once even when sixteen panes go at once, then each pane’s own unsaved-work question. A shell at its prompt closes silently; closing a pane whose configured program is the child (a full-screen tool) reports nothing running, because that pane’s close is the program’s close. The sidebar’s Reset is now Close all panes with a trash icon - it shared the counter-clockwise arrow with “change pane type” - and the palette command matches. IPC callers cannot answer a dialog, so kill-pane and layout-load keep refusing unsaved edits but treat an explicit kill or load as consent to stop what it replaces, and never raise a dialog in a GUI they cannot answer. paneStatus carries the new foreground_job field (true/false on Unix, null where detection is unavailable) for any other observer.

  • The first keystroke after launch reaches the shell. A fresh workspace left last_body unset and no pane held keyboard focus - _apply_active_workspace_view only activated a remembered pane, and nothing on the startup path ever set one - so typing did nothing until the user clicked a terminal (UX-01, verified on v0.5.6 and still present in 0.7.0). The view refresh now resolves a fallback: the workspace’s first terminal, else its first pane (so a read-only layout still has an active row), activated through the same activate_pane rule as a click. The same hole affected every other entry that builds panes without activating them - switching to a restored workspace that was never visited, and profile activation - and the one fallback fixes those too.