v0.7.0 - 2026-10-20
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.
paneStatusnow answers media panes too through a genericPaneBody.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 asnull. - 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-mediaand is streamed into anAudioStreamGeneratorat 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_u8is 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
ImageTexturefed by the pinned FFmpeg backend rather than Godot’s nativeVideoStreamPlayer, so.mp4,.m4v,.mov,.mkv,.webm,.avi,.tsand.m2tsjoin.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_pathrunpath, and a singlescripts/bundle-mediahelper places the matchinglibav*/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 inlicenses/, the AUR recipe installs them under/usr/lib/gptyand/usr/share/licenses/gpty, and the Linux packer refuses a bundle whose libraries do not resolve. A locally extracted Linux bundle starts withLD_LIBRARY_PATHunset. - A Rust media backend demuxes and decodes the wider format matrix. A new
gpty-mediacrate links a pinned, minimal FFmpeg build (9.0.2, LGPLv3, dynamically linked, no network protocols, only the components incomponents.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. TheGptyMediaGDExtension 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 closedMediaFilestable (absolute, regular file, known extension) and never reachesResourceLoader, so a file-supplied layout or profile cannot smuggle a.gdor.tscnin 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 andpaneStatustransport 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_jobis 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 loggedCondition "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 nextplay()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.
gptynow 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 aroundprocess::exit, the plausible shape behind a one-off WindowsSTATUS_HEAP_CORRUPTION(0xC0000374) on aninjectwhose CLI printed nothing. It also drops the worker-pool startup from every invocation: 300gpty versionruns 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, sokill-paneandlayout-loadkeep 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.paneStatuscarries the newforeground_jobfield (true/falseon Unix,nullwhere detection is unavailable) for any other observer. -
The first keystroke after launch reaches the shell. A fresh workspace left
last_bodyunset and no pane held keyboard focus -_apply_active_workspace_viewonly 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 sameactivate_panerule 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.