Items were fading in from alpha 0 via spring animation, preventing
immediate interaction. Removed the entrance animation entirely — items
now appear at full opacity and are movable on first frame.
- CropOverlay: register move/up handlers dynamically on drag start (single
handler instead of 8x per-handle), remove on drag end. Override destroy()
to call _cleanup() preventing keyboard listener leaks.
- Text format toolbar: re-measure text bounds (w/h) after fontSize/fontFamily
changes, update spatial index and transform box.
- TextEditor: add clearText() method; tools.ts uses it instead of fragile
document.querySelector('textarea').
- SceneManager._updateItem: apply crop mask on remote sync for image items.
- useCanvasSetup: stop TextEditor on unmount to prevent orphaned textarea.
- Double-click zoom: use item.data dimensions instead of getBounds() (which
includes shadow offset).
- Text tool: click-to-place immediately opens inline editor, auto-switches
back to select tool. Empty text cleanup on save.
- Crop: select image + press C (or right-click > Crop) to enter crop mode.
8 drag handles with rule-of-thirds grid, dimmed outside area.
Enter confirms, Escape cancels. Non-destructive (stored as normalized rect).
- Text format toolbar: appears when text items selected, with font size +/-,
font family dropdown, and color picker with presets.
- Double-click image: zoom-to-fit (PureRef-style focus)
- Entrance animation: replaced bounce with simple fade-in (no delay before
items become interactive)
- TextEditor: allow empty text (caller handles cleanup)
1. Move GIF loading behind TextureManager.loadGif/releaseGif with
ref-counting (fixes shared-source invalidation on duplicate GIFs)
2. Strip query strings/fragments before .gif extension check
3. GIFs now load paused — separate MAX_PLAYING_GIFS=8 budget in
culling system, only nearest N animate (others show static frame)
Register GifAsset extension for .gif loading, create AnimatedGifSprite
wrapper with lazy load/unload matching ImageSprite pattern, and detect
.gif assets in SceneManager to use animated rendering. GIFs stored as
type 'image' in scene format — no schema changes needed.
- Background media worker: polls media_jobs table, runs ffprobe+ffmpeg
with concurrency limit, generates video posters, emits socket events
- Non-blocking video upload: stores file + enqueues job, returns immediately
- Poster hydration on board load: GET /boards/:id injects poster/dimensions
from DB into canvas_state video objects
- SpatialGrid: fixed-cell (512px) spatial hash for O(nearby) culling instead
of O(all) item scanning, eliminates setTimeout violations
- Canvas-based video rendering: draws video frames to offscreen canvas then
uploads to GPU, completely eliminates GL_INVALID_OPERATION errors from
PixiJS VideoSource auto-update mechanism
- Server poster upgrade path: culling ticker and applyProcessedMedia() both
upgrade client-captured posters to server posters when available
- Pause restores server poster (paused video behaves like an image)
- Selection drag-end persistence: onObjectDragEnd broadcasts + saves + undo
- Live media:job:update socket handler patches scene data + VideoSprite
dimensions without broadcast fanout
Upload pipeline now extracts poster frame (JPEG) and metadata
(width, height, duration) from videos at upload time using ffmpeg.
Server:
- Add ffmpeg to Docker image (Alpine)
- video-utils.js: probeVideo() and extractPoster() using ffprobe/ffmpeg
- Upload response includes poster_asset_key and duration
Frontend:
- VideoObject gains poster and duration fields in scene format
- VideoSprite accepts posterAssetKey + TextureManager, loads poster
as a regular image texture on construction (no <video> needed)
- Server poster loaded/released via TextureManager ref counting
- addVideoFromUpload passes poster and duration through to scene data
- image-drop.ts forwards poster_asset_key from upload response
Result: videos with server posters render as images by default.
Zero <video> elements needed for thumbnails. Only explicit play
creates a media element.
Video memory:
- Lazy <video> creation: constructor makes placeholder only, initVideo()
creates element with preload='metadata' when near viewport
- Tiered budgets: 1 playing / 6 initialized / 4 poster textures max
- Aggressive teardown: offscreen videos lose <video> element, poster,
and all textures — zero memory for offscreen videos
- Poster textures dropped while playing (don't keep both resident)
- Dimension caching in scene data (nativeW/nativeH) avoids re-init
Server:
- HTTP Range support (206 Partial Content) for video seeking
- Proper end clamping and invalid range rejection (416)
Sync:
- Remove redundant broadcastSceneDebounced() from broadcastTransform()
- Remove duplicate broadcastElements() from drag-end handler
VideoControls:
- Event-driven (timeupdate/play/pause/seeked) instead of rAF polling
- Tracks actual HTMLVideoElement reference, rebinds on init/teardown
- Seek uses ref-based commit on pointerUp, not conditional onChange
Children converge ~25px toward combined center on group (gentle spring),
then reparent into a Container. Ungroup reparents back to viewport and
spreads children ~25px outward with matching animation.