feat(core): placeholderImage — shared pinned image placeholder while a texture loads - #97
Open
chiefcll wants to merge 6 commits into
Open
feat(core): placeholderImage — shared pinned image placeholder while a texture loads#97chiefcll wants to merge 6 commits into
chiefcll wants to merge 6 commits into
Conversation
…a texture loads Extends placeholderColor (#96) with a per-node placeholder image. While the node's texture is not loaded (initial load, freed-texture reload, permanent failure) the quad renders the placeholder image through the node's own shader, stretched to the node's dimensions. Fallback chain per frame: main texture -> placeholder image -> placeholderColor rect -> nothing. Lifecycle is pin-once instead of per-node ownership: the setter resolves the URL through the texture keyCache (src-only props, so every node using the same URL shares one instance — e.g. one image per poster size), sets preventCleanup, and eagerly priority-loads from idle states only (a state guard keeps N nodes from starting N duplicate fetches). Per-node loaded/failed/freed listeners drive the fallback state machine and are detached on swap, clear, and destroy so nodes never leak through the long-lived texture. The freed handler self-heals out-of-band frees (context loss, another node unpinning the shared URL) by re-pinning and reloading. Renderers additionally stop applying node.textureCoords while a placeholder shows — those coords belong to the main texture (resizeMode, flips) and would mis-crop a real placeholder image (the 1x1 white texture masked this). Once the image is showing it renders untinted; placeholderColor only colors the rect fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ently Covers the shared-placeholder contract explicitly: one cached instance and a single fetch across N nodes, every node notified on load, each switching to its own main texture independently, and one node's destroy leaving the others' subscriptions intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolves conflicts in CoreNode.ts (textureOwnership cache field landed next to the placeholderActive doc block) and CoreNode.test.ts (the placeholderImage suite and main's renderOnlyInViewport / texture ownership cache suites were added at the same spot).
Splitting the texture pick into an if/else left `let tx;` / `let texture;` uninitialized, so TypeScript's evolving-let inference widened them to `any` — `tx.type` and `tx.ctxTexture` in the per-quad hot path became unchecked member accesses (eslint no-unsafe-member-access). Annotate the declarations; type-only change, emitted JS is unchanged.
…Manager placeholderImage attached three per-node listeners to a texture the design deliberately shares across many nodes, so a 500-poster list put 1500 listeners on one emitter. EventEmitter.off is indexOf + splice, making teardown of such a list quadratic — at a page transition, the worst moment for it on a TV SoC. The three handlers were also class-field arrows, so every CoreNode in the graph allocated them whether or not it used a placeholder. Stage now owns a PlaceholderManager keyed by URL. It creates, pins and eagerly loads each shared texture once, subscribes one listener trio per URL, and walks its own subscriber list on a state change. Nodes subscribe and release; release is O(1) via a swap-pop against a stored index, the same trick Texture uses for renderable owners. CoreNode keeps only the two fields the per-quad path already read (placeholderTexture, placeholderTextureLoaded) as manager-written caches, plus two plain bookkeeping slots — and is back to exactly 4 per-instance closures, the pre-feature count. Both renderers are untouched, so the hot path is byte-identical.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
New per-node
placeholderImageprop, extendingplaceholderColor(#96) with image placeholders. While the node's texture is not loaded — initial load, freed-texture reload (#87 lifecycle), or permanent failure — the quad renders the placeholder image through the node's own shader (rounded corners/borders apply), stretched to the node's dimensions. The per-frame fallback chain is:main texture → placeholder image →
placeholderColorrect → nothingDesign: a stage-owned manager, pinned once per URL
Apps use a small number of distinct placeholder images (e.g. one per poster size class) across many nodes. The lifecycle therefore lives in a new stage-owned
PlaceholderManager(src/core/PlaceholderManager.ts), keyed by URL — not onCoreNode:keyCachewith src-only props — 500 posters across 3 size classes = 3 texture instances.preventCleanup = true, so the memory manager never frees it and the fix(textures): evict orphaned cached textures during cleanup #95 orphan eviction never touches it. Eagerly priority-loaded once, at entry creation.loaded/failed/freedonce when it creates the entry, then walks its own subscriber array. 500 poster nodes put 3 listeners on the shared texture, not 1500.Textureuses for renderable owners.EventEmitter.offisindexOf+splice, so a per-node-listener design made tearing down a shared row quadratic, at a page transition.CoreNodegained no bound handlers: it keeps the two fields the per-quad path already read (placeholderTexture,placeholderTextureLoaded) as manager-written caches, two plain bookkeeping slots, and one ordinary method the manager calls. The class is back to exactly 4 per-instance closures — the same count as before this feature.freedhandler is the self-heal: if the pinned texture is freed out-of-band — in practice a node whosesrcmatches the URL, sinceloadTextureTaskwrites its owntextureOptions.preventCleanuponto the shared instance — the manager re-pins and reloads. One handler decides, so there is no race between nodes. (GL context loss is terminal in this engine — the Stage emitscontextLostand the app reloads — so it is not a case this covers.)Trade-off documented on the prop: placeholder images are resident for the app lifetime — use a few app-level images, not per-item artwork. Cost is bounded by distinct URLs, not node count. Note that a pinned texture is unreachable by both orphan eviction and
renderer.cleanup(), so clearingplaceholderImageon the last node using a URL does not reclaim it.Reviewer notes
node.textureCoordsbelongs to the main texture (resizeMode, flips, sub-rects). The 1×1 white texture in feat(core): placeholderColor — solid color placeholder while a texture loads #96 masked stale coords; a real placeholder image would be mis-cropped by them, so placeholders now always sample full-quad.placeholderColoronly colors the rect fallback. Keeps "gray rect, then branded image, then poster" from becoming "gray-tinted branded image".Testing
CoreNode.test.ts(374 total pass), driving a realPlaceholderManagerrather than a mock, since the sharing behavior under test lives there: pin + eager load on first use; already-loaded shared texture used immediately and untinted; color-rect fallback until the image loads; image-only placeholder renders nothing until loaded; loaded main wins; freed main re-shows the placeholder; failed placeholder falls back to the rect; freed self-heal re-pins and reloads exactly once; destroy/swap/clear unsubscribe hygiene; 50 nodes on one URL keep a single listener set; out-of-order release keeps every survivor subscribed (swap-pop index integrity).examples/tests/texture-placeholder-image.tswith five deterministic states (failed main + placeholder rounded, same under the border shader, placeholder-404 → color-rect fallback, loaded image wins, no-fallback control). Certified on both backends —chromium-ciandchromium-ci-canvas— captured in Docker, twice, byte-identical.🤖 Generated with Claude Code