Rust conventions
-
Edition: 2024 (requires Rust ≥ 1.85)
-
Format: standard
rustfmt -
Async runtime:
tokio(globalLazyLockruntime in gdext) -
Grid sharing:
Arc<Mutex<TermGrid>>- lock briefly, clone the grid, release -
Thread Safety: Godot’s SceneTree is strictly single-threaded. NEVER call Godot methods, mutate nodes, or emit signals directly from background
tokiothreads. Instead, queue the state changes for GDScript to poll, or use Godot’s thread-safecall_deferred(). -
Lifecycle & Teardown: When a
GptyTerminalis destroyed (e.g.,queue_free()in Godot), the Rust side MUST ensure the spawned shell and backgroundtokiotasks are cleanly terminated (via theDroptrait) to prevent zombie processes or memory leaks. -
State directory creation:
gpty_ipc::transport::state_dir()creates its directory on both arms. The Unixprivate_state_diralways did; the Windowslocal_app_data()now creates%LOCALAPPDATA%\gptyon first use too. This is the contract stores rely on -vaults.db(VaultStore::open), the recovery copies and the plugin store open files inside it without creating the parent - and a fresh Windows profile used to fail the wiki index withunable to open database fileuntil the smoke’s seeded plugin record happened to create the directory. Do not move the creation into individual stores, and keep the Windows arm equal to the Unix one (a test pinslocal_app_dir’s creation on every platform). -
Vault index:
gpty_core::vaultis the wiki pane’s note store - one SQLite FTS5 file atstate_dir()/vaults.db, reached from GDScript only through the staticGptyTerminal.vault_index/vault_search(they answer about a directory, not a terminal, so a pane needs no grid). Its walk (scan_vault) is the single definition of what a note is, andMAX_NOTES(2000) /MAX_DEPTH(16) /MAX_NOTE_BYTES(1 MiB) live there so the pane’s list and the index cannot disagree - the pane must not grow a second copy of those rules. It reuses the scrollback store’s skeleton deliberately: the same pragmas, the sameuser_versiongate pattern (its own counter, now at 2 - the bump rebuilds the derived tables rather than migrating them), external-content FTS5 with the three triggers,restrict_to_owneron the db and its-wal/-shmsiblings, and the process-widewrite_guardheld innermost across one transaction - with a(vault_id, rel_path)key andporter unicode61, because notes are prose (“running” matching “run”) where terminal lines are not. A pass re-reads only notes whose mtime (nanoseconds; seconds would miss a same-second same-size edit) or size changed, drops rows for vanished notes, prunes rows whose vault root is no longer a directory, and never prunes from a truncated walk - the notes it did not reach would be deleted. Bodies are cut atMAX_NOTE_BYTESwith the torn UTF-8 tail dropped, the same cutTextReadmakes on the display path, so a hit can never name text the view does not show. Both FFI functions return JSON witherrorpresent only when the query could not run, so “no match” stays distinguishable from “not indexed”. -
A note’s label - the
titleavault_searchhit shows andnotes_ftsranks by (title, body) - isnote_title: the first heading past a leading, closed YAML frontmatter block (---…---/..., the shape Obsidian writes its properties as), else the file stem. The block never becomes the label (without the skip its opening---was the body’s first non-empty line and every such note was named by its stem), an unterminated---is not frontmatter so the scan behaves exactly as before, and notitle:key is read - that is a properties-UI decision, not a label rule. The block’s text stays in the indexed body: it is ordinary visible Markdown (the renderer shows it), so a property value remains searchable. -
Wikilinks (
gpty_core::links, thelinkstable,GptyTerminal.vault_links):[[target]]occurrences are parsed in the index pass - the pass already reads every changed body, so the parse is free, sits in the same transaction as the note row, and cannot drift from the text the FTS index holds. One row per occurrence (links(vault_id, src, seq, line, target, resolved), primary key(vault_id, src, seq), indexed on(vault_id, resolved)), because a backlink is not syntax: it is the same edge read in reverse (WHERE resolved = …), so nothing is authored twice and the per-mention line is what the panel lists. A fenced block, an inline code span or a backslash-escaped opener is literal text (a note documenting the syntax must not create edges); the caps are 512 occurrences per note and 1024 bytes per target; andalias/fragmentare parsed but never stored - the edge is to the note, and a fragment is navigation:links_forre-parses the note body it already holds and attaches each occurrence’s fragment byseq(the pane half below follows it). Resolution (settled - do not re-derive it elsewhere): a target containing/names a path (notematchesnote.md), a bare target names a file stem; exact case first, then a unique case-insensitive match; more than one match at either stage is ambiguous and is never silently picked.resolvedis a derived column, maintained in exactly two places:upsert_batchrewrites a row’s edges (resolving against the note set after its own inserts, so a batch that adds a stub and its linker settles both ways), andreresolve_linksre-derives the whole vault when the note set actually moved (added/removed) - a stub that appears resolves its linkers, a vanished target loses its resolution, a stem that is now shared stops resolving; an ordinary pass writes nothing. A removed note’s rows are deleted with it, andclear_vault/prune_dead_vaultsdrop a vault’s links. Theuser_version=2bump rebuilds the derived tables instead of migrating them (a v1 store’s notes were never link-parsed; the next pass re-reads the vault), and ambiguous candidates are derived at query time from the same rules rather than stored - no column for a list, and the answer cannot disagree withresolvedbecause both come from one note set. Nothing here executes: a link is an edge, never an action.The pane half (2026-10):
Linkcarries the occurrence’s byte span (start/end), and the Markdown renderer’s mention pass is the same parser, so what the preview links and what the index stores cannot disagree - a fenced block, a code span and an escaped opener are literal in both.GptyMarkdown.render(text, wikilinks)replaces each accepted occurrence with an opaque sentinel before Markdown parsing (emphasis, headings and tables around it are untouched, and the label is never re-parsed), then substitutes[url=gpty-note:<percent-encoded target>[#<percent-encoded fragment>]]label[/url]in the finished BBCode; the target and the fragment are each encoded so a space or]cannot end the tag, and the one literal#is the only separator (a#inside either part is percent-encoded). A mention written inside a Markdown link’s label (or an image’s alt text) yields to its container and renders as plain label text: nested[url]tags leave the click target undefined, and the outer link is what the author explicitly wrote. The flag is off for every non-wiki caller (MarkdownView.wikilinks_enabled; Inspector, Reasoning and code previews keep literal[[…]]), and only this renderer can emit the scheme (safe_urlstill admits just http/https/mailto, so a document’s own Markdown link cannot impersonate a mention).MarkdownViewnever opens it: it emitsnote_link_clicked(target, fragment)(both percent-decoded) and the owning pane decides. Following resolves throughGptyTerminal.vault_link_resolve→VaultStore::resolve_target, which runs the index’s ownNoteIndexover the index’s note set (note_paths, now shared withlinks_for/reresolve_links) rather than a second GDScript rule - so an ambiguous stem answers with its candidates and is never picked, and a mention typed but not yet saved resolves too, because resolution needs the note set rather than the source note’s stored rows. The wiki pane’s link panel (Links to/Linked from) shows one row per occurrence with its line, an unresolved row names the target (with the candidates when ambiguous) and is not clickable, and a resolved/backlink row follows in place through_request_leave(Save/Discard/Cancel; no history stack in v1 - the notes list is the escape). A resolved outgoing row lands on the heading its mention named (fragment, re-derived bylinks_forfrom the stored body - the table still stores no fragment) and a backlink row opens the linking note at the mention’s line; a mention into the note already open replaces nothing and only moves the view, so it asks nothing. The landing is derived in two steps:MarkdownAnchorsscans the source for ATX headings (leading frontmatter and fenced blocks skipped) to answer which heading a fragment - exact text first, then case-insensitive - or a source line (its nearest heading above) means, andMarkdownView.scroll_to_headingfinds that heading line in the rendered parsed text, becauseRichTextLabel.scroll_to_linecounts rendered lines while the index counts source lines. The preview scroll is queued for one frame (the label has no visual lines before its layout pass;get_character_lineis -1, measured on 4.7.2), and the view skips an identical re-render - the editor’s settled render 0.4 s after a note opens would otherwise reset the scroll - while an actual render cancels a pending scroll. Edit mode scrolls the source by line directly. A fragment the note does not contain still opens the note and is reported; a line with no heading above it lands at the top, which is its nearest derived position. The panel is rebuilt when a note opens and again after a save’s re-index, and is capped atMAX_LINK_ROWS(50) per section with the remainder counted, because the parser admits 512 occurrences and backlinks are unbounded. A click is a read: resolution answers a vault-relative note and_open_noteloads it throughvault_note_read, with the editor’s containment, cap and regular-file gates unchanged. -
Link graph canvas (
GptyTerminal.vault_graph→VaultStore::graph→VaultGraphModel→VaultGraphView): the wiki pane’s Graph view draws the vault’s own link structure - no new file type, no second store, and nothing written. The store answers{"nodes", "edges", "truncated"}fromvaults.db: every indexed note (labelled bytitle), aghost:<target>node per distinct unresolved target, and one aggregated row per ordered(src, resolved)pair with itsCOUNT(*)of mentions (then the unresolved(src, target)rows), deterministically ordered (count DESC, then src/dst; notes by path, ghosts by target).MAX_GRAPH_EDGES(10k per class) andMAX_GRAPH_GHOSTS(2k) settruncatedinstead of drawing part of a vault as if it were all of it, andgraph_limitedis the test seam for the caps (thescan_vault_limitedpattern). The pane queries it when the Graph button is pressed - every mutation (save/create/rename/delete/link rewrite) already re-indexes - andVaultGraphModel(pure, unit-tested) normalizes the answer: self-references are dropped (the panel lists them), the two directions of a pair become one edge carryingab/ba/mutual, stroke width and layout springs are logarithmic and clamped (EDGE_MIN_WIDTH..EDGE_MAX_WIDTH,MAX_SPRING) so one hub cannot drown the drawing or collapse the graph, and the layout is deterministic (sunflower seed + FNV-1a jitter, neverhash(); a size-bounded Fruchterman–Reingold relaxation with grid-approximated repulsion, because the walk’s 2000 notes make all-pairs repulsion 4M pairs per sweep).VaultGraphViewis one custom-drawnControl, not aGraphEditof node controls (the 2000-note ceiling is where that renderer stops being right): pan/zoom/drag are session geometry that a re-query preserves (only unseen ids are placed, near a neighbour; Relayout discards every drag for the deterministic arrangement), off-screen nodes/edges are culled, labels appear for all nodes only aboveLABEL_ZOOM(hovered/selected always), arrows are an optional toggle with mutual pairs drawn double-headed, and ghosts are outline circles. A node click opens the note through the same leave guard as the list; a ghost click runs the preview’s own_follow_target(missing → create-from-link, ambiguous → report - never a guess). Display-only: nothing in the canvas reads a path the index did not name or writes anything. -
Declared edge weights (
LinkWeights,LinkWeightsStore,vaultWeight): the link graph’s authored layer. Measured weight isCOUNT(*)mentions, a crude proxy - two notes can be deeply related and mention each other once, or name-drop ten times and share nothing - so the user (a right-click Set/Clear declared weight control on an edge) or an agent (gpty vault weight <vault> <src> <target> --weight N/--clear; MCPvault-weight) may state one number that overrides it for display. Rules, settled 2026-10: (1) never invaults.db- that store is a derived cache a schema bump rebuilds, and authored work must survive a rebuild; the map is user-owned (user://link_weights.json,{"vaults": {<absolute root>: {src: {target: weight}}}}, thePaneEnvStore/TrustedStoreshape: typed reads, capped atMAX_VAULTS32 /MAX_ENTRIES1024 per vault and weight 1–100, atomic temp+rename 0600). (2) Display-only, absolutely:VaultGraphModel.apply_weightssetsdeclaredandweighton the parsed edge and never touchescount; thickness, the layout spring and link-panel order readweight, while the drawn edge set, navigation, writes and every trust decision read only the index - an authored weight can never hide an edge, create one, gate a click, trigger a write or count as consent (the Tier 2 declaration rule). (3) Pair-shaped write, directed keys: the canvas draws one edge per note pair, so its control writes both directions of(src, target)(set_pair/clear_pair); the IPC verb writes exactly one directed pair; the model takes the larger of a pair’s two declarations. (4) Pruned lazily, never on a truncated answer: showing an untruncated graph drops declarations whose endpoint is no longer an indexed note; a truncated answer must not, because the notes it does not name are still real. An in-app rename re-keys both endpoints (LinkWeightsStore.rekey_note, one write, run by the pane immediately after the verb succeeds and independent of the optional mention rewrite) before any complete graph can prune the old name, so the curation follows the file; a stale destination row (a rename requires that note to be absent) merges with the moved row, and the moved declaration wins the shared targets. An outside move is indistinguishable from a delete to the index and still prunes (queued in Future) - the prune is the only thing that bounds the file. (5) The GUI answersvaultWeight(the store isuser://, which the CLI cannot resolve), so the agent verb needs a running GUI and never auto-spawns one. -
Note editing (
vault_note_read/vault_note_write): the wiki pane’s editor reads and writes one note through the same module, so the vocabulary keeps one owner.read_vault_notereuses the index’s cut (read_file_capped), refuses any path the walk could not have produced (relative, no hidden component at any level,.mdlast), and requires a regular file - a FIFO planted in a vault must not reachFile::open, which would block the GUI thread the FFI runs on. It answers the file’s CRLF shape and a note stamp - mtime in microseconds, not the index’s nanoseconds, because the stamp round-trips through JSON, whose numbers are doubles (1.7e18 ns loses its low bits there; 1.8e15 µs is exact) - plus size.write_vault_noteedits an existing note and never creates one: the path goes through the same vocabulary, the canonicalized result must stay under the canonical vault root (so a note symlinked inside the vault is written through and one pointing outside is refused), the content is capped atMAX_NOTE_BYTES, and a stamp that differs from the one the caller loaded is refused withNoteError::ChangedOnDisk(code: changed_on_disk) unless the caller forces it. The replacement is a hidden sibling temp file (random name viatemp_suffix, never a fixed one) renamed over the target, which keeps the note’s own mode - the temp file is created with the process umask, and a vault may be shared - and cannot truncate a file it fails to rewrite.vault_note_writere-indexes the vault before answering (indexed: falsewhen the save landed but the index could not), because the file is the index’s source of truth and a caller must not have to remember a second call. OnlyOutsideandIofailures are logged: a bad path or a stale stamp is a refusal the caller asked about, and the pane reports it through its toast or overwrite dialog.Creating, renaming, and deleting (2026-10): the editor’s other file verbs live in the same module and are held to the same vocabulary - a vault-relative
.mdpath with no hidden component - with three distinctions that matter. (1) They act on the vault entry, never through it: a symlinked note is unlinked (delete) or moved as a link (rename), and its target stays where it is, because deleting must not reach a file the vault does not contain; reads and writes keep following links, with theOutsiderefusal. (2) Creation and the rename destination must have a directory that already exists and canonicalizes inside the vault - creating through a directory symlinked out of the vault would write outside - and never make directories. (3) A create or rename claims the destination withcreate_newbefore anything else (renamethen replaces only its own claim, and removes it if the rename fails), so an existing note, symlink or directory answersNoteError::Exists(code: exists): there is no overwrite path and no check-then-write race. A rename preserves the file’s mtime/size (the caller’s stamp stays valid) and re-indexes like a save, soremoved+addedre-derive the link table - links by the old stem stop resolving, links by path follow it. The pane wires these to the list/toolbar: New note (defaultUntitled.md, a bare name gains.md, a collision re-opens the same question with the typed text), Rename and Delete on the open note (delete is a confirmation that also says unsaved work will be lost), and create-from-link for an unresolved mention or panel row - missing only, never an ambiguous stem, which stays a report. A dirty buffer’s recovery copy is re-keyed on rename and dropped on a confirmed delete;_has_open_askcounts only visible dialogs (an answered one is hidden before its action runs), because the create-from-link question’s success may open the leave question for the note being edited. A rename also offers the reference update as its own step (GptyTerminal.vault_link_rewrite_previewthenvault_link_rewrite; corepreview_link_rewrite/rewrite_links_after_rename): the rename makes mentions of the old stem/path unresolved, so the pane asks - with the count - before any other note is written, and only the occurrences that resolved to the old name before the move are respelled (alias and#fragment preserved, CRLF kept, fenced code and code spans untouched because the parser defines what a link is). The note at its new path is a candidate only when its buffer is clean: the pane passes that state asinclude_renamed(the preview and the apply take the same flag, so the question’s counts are what can land), a dirty buffer passes false and is never written under, and the apply answersrenamed_note- the text that landed plus its fresh stamp - so the editor adopts both in place rather than re-reading (a save right after therefore does not see a stale stamp). A new name a link cannot spell ([,],|,#or a control character would be re-read as syntax, and a control character breaks the line-oriented parse) refuses the whole pass rather than writing a broken mention; a note over the read cap is skipped rather than written back as a prefix. The write goes throughwrite_vault_noteand the apply re-plans in the same call, so an edit made while the question was open is read fresh and only a note that changes between that plan and its write answerschanged_on_disk(counted as skipped); the apply re-indexes, so the new backlinks appear at once. -
Note recovery copies (
vault_scratch_read/vault_scratch_write/vault_scratch_clear,gpty_core::vault::write_scratch/read_scratch/clear_scratch): the buffer lives in RAM andwrite_vault_noteruns only on explicit save, so a crash or an outside kill takes every unsaved edit - these three statics are the other half, a snapshot of the dirty buffer kept outside the vault and offered back when the same note is reopened. The layout isstate_dir()/scratch/<fnv1a64(canonical root, NUL, rel path)>.json, written atomically (hidden random temp + rename) with the directory at 0700 and the file at 0600, FNV-1a spelled out locally rather thanDefaultHasher(whose algorithm Rust does not promise across releases - a toolchain bump would orphan every copy). The record repeats the identity it was written for (vault,path,mtime,size,text), and a read that does not match answers absent - a stale file, a collision, or an unparseable record can never restore the wrong text - as does an unreadable one, since an absent copy is the normal state and no scratch failure is toasted (the pane cannot offer a copy that was never written, the buffer is on screen, and its own save reports what a full disk means).scratch_errorlogs under the rulenote_failure_is_diagnosticalready defines - only the failures nobody asked for - because a refusal a caller requested on purpose must stay quiet: GUT counts an engine error as a failure, and the wire test asks forbad_pathdeliberately. The copy’stextis the editor’s LF buffer space - the same space_saved_textis normalized into, or a CRLF note would open already dirty, write a copy of its own file, and offer it back as an edit nobody made - and itsmtime/sizeare the stamp the buffer was loaded from, so a restore after the file moved on still hitschanged_on_diskand the pane’s overwrite dialog instead of silently overwriting. The pane’s write is a throttle (SCRATCH_DEBOUNCE, armed by the first dirty edit and not restarted per keystroke: a restarted debounce never fires during a long typing run, which is exactly when the most work is at risk), and every deliberate end drops the copy - a save, the Discard answer of the close ask, and the vault change the settings panel performs without a dialog (keyed to the vault the buffer belonged to). A restore is never automatic (aRestoreConfirmdialog: Restore / Discard / Escape = not now), is refused while the note is read-only (a restored buffer could not be saved and the close guard would not see it as unsaved work, so the offer would be a promise the pane cannot keep - the copy is kept instead), and joins_has_open_askso it cannot stack on another ask. Nothing on this recovery path touches a vault: a save or a file verb (_create_note/_rename_note/_delete_open_note) is the pane’s only note write, and a copy is never one. The copy sharesMAX_NOTE_BYTESwith the note it copies, so a buffer past that cap gets no copy -write_vault_noterefuses its save by the same limit, and the two must not disagree about what can be written back. A prune rides every write (prune_scratch, the first thingwrite_scratchdoes - the 1.5 s throttle is the only writer, so it is a few short directory listings a minute at most, and running it before the copy is located means even a write refused for a vault that is no longer a directory tidies the directory): a copy is dropped only when its own record names a vault that is definitively gone - the path is absent, or exists as something that is not a directory - because anything else (a permission error, an unmounted network path, an I/O error) cannot say the vault is gone and a vault that is merely hard to reach must keep its copies. A copy whose note is missing is kept too: nothing on this path walks a vault, so a note that merely was not listed (a truncated walk) can never cost the work this feature exists to keep, and a deletion needs a record that says “unreachable” - never one the code could not read, parse, or find a vault in, and never a name the module did not build. Stale.gpty-scratch-*.tmpremains of a write that died mid-rename are reclaimed on the same pass once older than an hour (a live temp lives microseconds, so no writer can lose one to the age check). Every prune failure is a diagnostic - housekeeping never fails the write it rides on. -
The vault’s public API is native IPC, not a pane (
vaultSearch/vaultRead,crates/gpty-gdext/src/vault_ipc.rs):gpty vault search <vault> <pattern>andgpty vault read <vault> <path>(MCPvault-search/vault-read) expose the knowledge base to agents through the same control socket as every other primitive. Both are answered in Rust, registered inipc.rsbesideversion/shutdown, because a vault is a directory rather than workspace state - there is nothing forWorkspaceIpcHandlersto consult - and the walk plus SQLite must run on the blocking pool (spawn_blocking), not on the GUI thread that pollsdrain_ipc_requests. Search re-indexes before it queries (index_vault: changed notes only, bounded byMAX_NOTES/MAX_DEPTH; itstruncatedflag rides in the answer), so a note edited outside gPTY - by Obsidian, an editor, another agent - is searchable without any pane reopening the vault; the cost is one stat walk per query, not a write. Read is a read:read_vault_notesupplies the path vocabulary, the regular-file gate and the 1 MiB cut, and neither method writes to the vault. Errors are JSON-RPC:-32602for a malformed request (missing param, non-absolute or non-directory vault) and-32000withNoteError::code()indata.codefor a refusal, so a client can tellnot_foundfrombad_pathwithout parsing prose. No vault command auto-spawns a GUI (may_autospawnreturns false, the v0.5.6 rule): search and read are queries, and the weight write needs the GUI’s store but still refuses to create one.vaultWeightis the one vault method that is not native: its store is the user’suser://link_weights.json, which only the GUI can resolve, so it is registered ingdscript_methodsand answered byWorkspaceIpcHandlers(display-only; it writes that store and nothing else). -
Media backend (
gpty-media, v0.7.0): the only crate that links FFmpeg.scripts/media-depsdownloads the SHA256-pinned FFmpeg 9.0.2 tarball and builds the LGPLv3 shared library set fromcrates/gpty-media/components.txt(--disable-everything --disable-network, decoders/demuxers/parsers only); Windows uses vcpkg.components.txtis the single component policy, andtests/build_recipe.rschecks it against the runtimeALLOWED_*lists in both directions - adding a format means editing that file, not a second list. Every job that compiles or loads the GDExtension needs the prefix (./scripts/media-depsappends the env in Actions); localci-checksourcesdist/ffmpeg/<platform>-<arch>/env.sh.- Trust boundary:
validate_pathis applied in Rust as well as GDScript (absolute, local, regular file; a FIFO/device never reaches FFmpeg), the demuxer allowlist is checked after open (input.format().name()reports an alias list, e.g.mov,mp4,m4a,3gp,3g2,mj2, so any alias must match), and the selected stream’s codec is checked by codec id (Id::name(), stable) rather than the implementation name (libdav1dforav1). Attached-picture video streams (cover art) are skipped, not treated as video tracks. Caps:MAX_STREAMS16,MAX_VIDEO_DIMENSION4096,MAX_FRAME_BYTES34 MiB,MAX_CHANNELS8,OPEN_TIMEOUT_MS10 000 (an interrupt callback, cleared once open succeeds). - Decode model: one
std::threadper session - never the Godot thread and never the two-worker tokio runtime. Bounded queues (VIDEO_QUEUE_FRAMES2,AUDIO_QUEUE_CHUNKS16) provide backpressure;send_boundedwaits on a full queue while checkingstop, so a pane that stops polling cannot deadlock close.poll_video/poll_audioare non-blocking.seekis acknowledged by the worker (the caller drains queued frames while waiting, so a worker blocked on a full queue still reaches the command); the worker increments an epoch before the ack, frames carry the epoch they were queued under, andpoll_*discards older ones. Per-stream cursors drop frames decoded from the keyframe before the target. The worker stays alive after EOF so a seek/replay can restart it (Finishedis not a death). - Binding rules (asserted by
tests/binding_rules.rs): never useInput::packets()(its iterator’smem::transmute_copyrelies onrepr(Rust)field order), container metadata (not necessarily UTF-8), frame dimension setters,sample::Buffer, filters or subtitles. Packets are read withPacket::readand resolved by stream index.ffmpeg-the-thirdis pinned; the facade keeps the binding replaceable. - Output: video is tightly packed RGBA8 (
width * height * 4) fromswscalewith row-stride repacking; audio is interleaved f32 fromswresampleat the caller’s rate/channels, accumulated into ~2048-frame chunks. PTS are microseconds.GptyMediareturns one frame/chunk per call asDictionary+PackedByteArray/PackedFloat32Array(empty dictionary = nothing queued);GStringinputs are JSON. - Licensing: the FFmpeg build is LGPLv3 (
--enable-version3, no--enable-gpl) and dynamically linked; ship its license texts with release artifacts. The build is a media attack surface: no sandboxing claim, no hardware decode yet, no network protocols compiled in.
- Trust boundary: