save_beam re-encoded every resident raster keyframe to PNG (+ proxy) on every
save, even untouched frames — the dominant per-save cost for painting/animation
projects (the code noted this as deferred "Phase 3").
The infrastructure to do it incrementally already exists: kf.dirty means "current
pixels not yet in the container" (set on any edit, cleared on a successful save,
per main.rs), and it's preserved in the document clone the save worker receives.
Gate the encode on it: a clean keyframe already stored keeps its full + proxy rows
untouched; only dirty (or not-yet-stored) frames are re-encoded. Media blobs were
already incremental; this closes the raster gap.
No new data-loss risk: the mid-save-edit race (edit between the document clone and
save completion) is pre-existing and identical to the old full-write path.
The prior audio-tags commit put real FLAC + metadata into export/audio_exporter.rs
— which turned out to be dead code (declared, never called; whole file was
EngineController::start_export_audio → daw-backend's export_audio, which still
routed FLAC to the erroring hound stub — hence "not implemented in daw-backend".
Move the work to where export actually happens:
- daw-backend/src/audio/export.rs: real ffmpeg FLAC (16-bit S16 / 24-bit S32,
skipping the trailing empty flush packet the FLAC muxer rejects); apply_metadata
on MP3/AAC/FLAC output; RIFF LIST/INFO chunk appended to WAV. New metadata field
on the backend ExportSettings, threaded from the UI in run_audio_export. Tests
assert real fLaC magic + round-tripped tags, and a valid WAV INFO chunk.
- Delete the dead export/audio_exporter.rs (removes the duplicate FLAC impl).
Smart tag defaults (filled only when empty, never clobbering edits):
- Year → current civil year, computed from the system clock with i64 math (no
date crate; correct past 2038/2106 — tests cover post-i32/u32 timestamps).
- Artist → last-used value, else the OS username ($USER/%USERNAME%).
- Album → last-used value.
Last-used Artist/Album persist in AppConfig and prefill next export.
- FLAC is now real FLAC via ffmpeg, not WAV bytes in a .flac file. 16-bit uses
S16, 24-bit uses S32 (ffmpeg's flac encoder emits bits_per_raw_sample=24).
The flush emits a trailing empty packet that the FLAC muxer rejects as
"invalid data" — it's skipped.
- Tag metadata (title/artist/album/genre/year/track/comment) written into every
format via each container's native tags: ID3v2 (MP3), MP4 atoms (M4A), Vorbis
comments (FLAC) set through ffmpeg's output metadata; RIFF LIST/INFO appended
to the hound-written WAV (with a fixed-up RIFF size). New AudioMetadata type
on AudioExportSettings; dialog gains a Tags section and defaults Title to the
project name.
Tests: FLAC is a real fLaC container with round-tripped tags; WAV keeps a valid
RIFF with a working INFO chunk.
SVG export silently dropped Text layers (they fell through the layer_to_svg
catch-all) while the dialog implied only raster/video/effect were excluded, so
title/caption text vanished from a "lossless" export with a success message.
Emit text as actual glyph-outline <path>s: lay the text out with the same
parley path the renderer uses, then extract each positioned glyph's outline
with skrifa (an OutlinePen that maps points into document space — Y flip,
synthetic-italic skew, variable-font normalized coords). Result is
font-independent and needs no <text>/@font-face. Vello rasterizes glyphs on the
GPU and doesn't expose the path, but the skrifa outline API it uses is directly
callable and parley's glyph IDs are real font GIDs, so the outlines match.
Synthetic bold is not applied (rare). Adds a skrifa dep pinned to parley's 0.43.
Three cases where an export produced something that didn't match what the UI
offered:
- WebP quality slider was a no-op: image 0.25's WebP encoder is lossless-only,
so the slider did nothing and files were needlessly large. Encode lossy WebP
via ffmpeg's libwebp instead (already linked); the quality knob is now real
and alpha is preserved as YUVA420P. Test asserts a lossy VP8 chunk + that
quality changes file size.
- ProRes 422 always failed to open: the SDR path fed prores_ks 8-bit YUV420P,
but it requires 10-bit 4:2:2. Add a CpuYuv422P10Converter (RGBA→YUV422P10LE,
BT.709) and route ProRes through the existing async pipeline in CPU mode;
setup_video_encoder now emits YUV422P10LE + prores_ks HQ profile and
encode_frame handles 4:2:2 chroma. Test guards that the encoder opens.
- VP8+audio failed at mux: the parallel path wrote the temp video to a
hardcoded .mp4, which VP8 can't live in. Derive the temp container from the
codec (VP8/VP9 → .webm).
New export format alongside audio/image/video/SVG. GIF is multi-frame like
video but palette-quantized with no audio, so it reuses the per-frame RGBA
render/readback path (render_frame_to_gpu_rgba) and streams frames to a
background encoder.
- core: GifExportSettings (resolution, framerate, loop, transparency, fit,
time range) with centisecond-quantized frame delay + tests.
- gif_exporter: encoder pipeline. Per-frame NeuQuant quantization is the
dominant cost and is per-frame independent, so it's fanned out across a
worker pool (cores-1, capped 8); a writer thread reorders and LZW-encodes
sequentially. Uses the `gif` crate directly (already resolved via `image`).
- orchestrator: start_gif_export + render_next_gif_frame (one frame per egui
update), wired into is_exporting/has_pending_progress/cancel.
- dialog: GIF tab + settings; main.rs: handle ExportResult::Gif and pump frames.
- Cargo: opt-level=3 for gif/color_quant/weezl in the dev profile so debug
builds aren't crippled by unoptimized NeuQuant loops.
Together these cut a 10s GIF export from ~1:43 to ~3s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The backend's Command::Reset (full teardown: rebuilds Project, audio/buffer
pools, ID counters) was never invoked from the UI. New File and project loads
only cleared app-side maps, so the previous file's tracks and loaded
instruments stayed resident in the backend and kept getting mixed on every
audio callback.
Add a reset_audio_backend() helper (controller.reset() + clear app-side track
maps and backend-derived caches) and call it in the three teardown paths:
NewFile, create_new_project_with_focus, and apply_loaded_project. Also drops a
duplicate layer_to_track_map.clear() in the NewFile handler.
Race-free: the audio thread drains all command_tx commands before any query_tx
queries each callback, so Reset (a command) runs before the track/pool rebuild
(queries), even though they travel on separate channels.
Fixes from a review of the mobile-UI branch.
- Guard the inspector size clamps so min<=max (f32::clamp panics otherwise) and set a
window min_inner_size; prevents a crash / degenerate layout at small sizes. Same
small-size guards for the piano-roll landscape toggle bar and the intent grid.
- Landscape 2->1 edge reveal: snap to fullscreen at frac>=0.5 (the preview's phase
boundary) instead of COLLAPSE_HI, so releasing mid-drag no longer jumps backward.
Both edges.
- Gate the double-tap-drag marquee to mobile so it can't hijack a desktop Brush/Draw
drag.
- Timeline mobile long-press: request a repaint while counting down so it fires even
when the finger is held perfectly still.
- Landscape folded top bar: lay the filename+search+overflow cluster out within a
reserved center span (gutters for the pane's own label/buttons) and elide the
filename, so it can't overlap or overflow the header.
- Node editor connect/disconnect: assert + document the top-level-graph assumption.
- Palette search field focuses only when unfocused (no per-frame focus stomp); reset
the inspector tap-anchor on orientation change; remove stale main.rs.backup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gate the top menu bar on !mobile_active(). Its commands are all reachable from the
mobile shell (⌕ command palette, ⋯ overflow, and the stage/timeline context menus),
so the desktop menu bar is redundant on mobile and just wastes a strip of vertical
space. Desktop unchanged.
Make the phone shell orientation-aware (portrait vs landscape). Orientation is
aspect-based (rotation = a resize the shell re-lays-out each frame); LB_MOBILE_UI=2
opens a landscape dev window. `SharedPaneState.is_portrait` is published to panes.
- Stack caps at 2 panes in landscape (max_panes through the R1–R6 drag ops), with a
per-frame clamp so rotating while 3 are open drops to 2.
- Edge drags are a continuous full-height reveal instead of stalling at the capped
trigger: 1-pane → split → next-pane-fullscreen, and (landscape 2-pane) a two-phase
slide-then-collapse to 1 pane. Both top and bottom edges, mirrored. Release snaps to
stay / split / fullscreen. Dividers resize and collapse-to-1 at the extremes.
- Inspector becomes a right-side vertical column in landscape (inspector_width_frac,
left-edge horizontal resize); portrait keeps the bottom sheet.
- Piano roll: vertical "Synthesia" view in portrait; landscape shows a Keys/Notes
segmented toggle over the conventional horizontal roll / full keyboard.
- Start screen reflows to 2 rows × 3 with the recent list beside it in landscape.
- Landscape headers are shorter (52→34px) and the top bar folds into the middle of the
top pane header (no separate band), reclaiming vertical space.
All gated to mobile; desktop unchanged (is_portrait defaults true).
Hook up menu-bar actions that lacked a mobile affordance, and implement z-order.
- Stage/object long-press menu (stage.rs): add Paste (on clip, geometry, and
empty stage) and Send to Back / Bring to Front on the selection.
- Timeline long-press menu (timeline.rs): the timeline had no mobile context
menu — add one via manual long-press detection. Clip actions (Split,
Duplicate, Cut, Copy, Paste, Delete) on a clip; animation actions (New/Blank
keyframe, Add keyframe at playhead, Duplicate keyframe, Delete frame, Add
motion/shape tween) on an empty lane. Gate the desktop right-click menu to
!is_mobile so mobile shows only the long-press menu.
- Implement Send to Back / Bring to Front (were // TODO no-ops): new undoable
ReorderClipInstancesAction in lightningbeam-core reorders the selected
instances within their layer's clip_instances Vec (stacking order; last =
on top; geometry stays flattened underneath). Wired via handle_menu_action.
Replace the desktop node-graph canvas with touch-native views on mobile
(wireframe Plate 07), gated on is_mobile in NodeGraphPane::render_content; the
desktop draw_graph_editor path is untouched. New submodule
panes/node_graph/mobile.rs.
Focus view:
- The focused module's parameters as touch controls (slider / dropdown / field
by the desktop widget rule, with a visible slider rail); editing mutates the
graph ValueType so the existing check_parameter_changes dispatches
SetParameterAction.
- A full-width minimap strip (tap the nearest node to focus it), travel chips
naming connected endpoints (tap to jump), and an Add-node picker that creates
the frontend node + AddNodeAction and focuses it.
- Bespoke nodes (Sampler/Script/Sequencer/AmpSim/Oscilloscope) render their
existing desktop bottom_ui; sampler/script-sample loads are wired (other
custom interactions are a case-by-case follow-up).
Patch view:
- Per-section egui::Grid so input/output port arrows align in a column. Each
port's Lucide direction arrow (from-line/to-line, tinted by DataType) is
clickable to arm a cable; a compatibility-filtered picker completes it
(ConnectAction). Cable chips (remote-node · remote-port) tap to disconnect
(DisconnectAction). Parallel cables allowed.
Also: add ARROW_RIGHT_FROM_LINE / ARROW_RIGHT_TO_LINE to mobile/icons.rs
(codepoints extracted from lucide.ttf via ttx).
The mobile Piano Roll becomes a unified, keyboard-primary instrument surface
(wireframe Plate 04/08): a playable keyboard as the base, revealing a
Synthesia-style falling-notes roll above it when the pane is tall enough.
- Shared keyboard geometry (panes/keyboard_layout.rs): width-driven, pan-aware
pitch->x, so the roll's note columns stay aligned with the Virtual Piano keys.
Shared keyboard_octave + keyboard_pan_x in SharedPaneState.
- Piano Roll vertical mode (is_mobile): notes as columns falling toward an amber
now-line by the keys, tempo-map converted (beats->seconds) so onsets cross the
line exactly when they sound. Vertical drag scrubs the timeline; horizontal
drag smoothly scrolls the keys (snaps to nearest key on release). Long-press
creates a note (drag to size) or resizes an existing one; pan suppresses it.
The keyboard is embedded (reuses Virtual Piano render + MIDI); the standalone
VirtualPiano stack slot is removed. show_roll is driven by the snapped pane
size-class so the keyboard<->roll reveal lands on a stack snap point.
- Virtual Piano: renders via the shared layout on mobile, colors playback_notes
like pressed keys, gates note-on/glissando to presses that start on the keys
(never gating release), and hides QWERTY hints on mobile.
- Transport formats by document.timeline_mode (Measures->bar.beat.tick,
Frames->MM:SS:FF, Seconds->MM:SS.mmm) — per project type, like desktop.
- In-pane instrument header (name + Presets + REC). Recording is driven from the
app each frame (not the Timeline pane's render) so REC works regardless of
visible panes, and stopping playback stops recording.
- Compose/Record intent opens Timeline + instrument pane; mobile central panel is
full-bleed (no inner margin) so panes sit flush.
- phone-ui-sketches.html: inst-bar moved to the top of the music surface.
- Mobile double-tap is now a single tool-independent priority chain: object →
enter (movie clip or group); empty inside a clip → exit one level; empty at
root → zoom-to-fit. Desktop keeps its own select-tool double-click (gated).
- Inspector "tap outside to dismiss" now only HIDES the sheet (with a dismissed
flag reset on selection change) instead of clearing the selection — the earlier
clear clobbered the selection before menu/omnibutton actions ran, breaking
Group/Convert/Cut/Copy. Delete also clears focus so it dismisses the inspector.
- Breadcrumb shows the full editing path (Scene 1 › … › current) via a new
SharedPaneState.editing_clip_path / EditingContext::clip_path(); each ancestor
crumb jumps to that level (pending_exit_to_depth → EditingContext::exit_to_depth).
- Remove the "Vello Stage" debug overlay; move the breadcrumb up to the top-left.
Gestures (Stage + Timeline):
- Pinch-zoom via zoom_delta() (touch pinch + Ctrl+wheel), unified with the raw-wheel
path so Ctrl+wheel zooms exactly once; plain wheel zoom + trackpad pan preserved.
- Double-tap empty → zoom-to-fit (Stage: artboard via zoom_to_fit; Timeline: new
fit_to_project over the full duration).
- Double-tap-drag on empty → transient marquee regardless of the active tool.
Long-press context menu: a shared SharedPaneState.mobile_context_menu that panes
populate with (label, MenuAction) items on secondary_clicked(); the mobile shell
renders one persistent popup (styled like the timeline menu) and dispatches via
pending_menu_actions. Stage offers Cut/Copy/Duplicate/Delete for clip instances and
Cut/Copy/Convert-to-movie-clip/Delete for geometry.
Fixes surfaced along the way:
- Geometry delete now frees selected fills (free_fill) and GCs isolated vertices
(new VectorGraph::gc_isolated_vertices) — no more orphaned fills / phantom snap
points; applies to the Delete key too.
- clipboard_delete_selection clears focus so deleting dismisses the mobile inspector.
- Mobile inspector: appears on pointer release (not press) so drags aren't
interrupted; reflows only when the tapped point is actually behind the sheet; taps
outside the sheet dismiss it; rounded-corner border no longer detaches.
Add mobile::apply_touch_style(ctx) — applied every frame when mobile is active —
enlarging egui's global spacing/sizing (interact_size, button padding, slider
width/rail, combo width/height, icon sizes, scrollbar, body/button text) so the
standard widgets in panes and dialogs are finger-sized. Desktop is untouched.
Rework the mobile inspector header into a single row using real egui widgets:
title (left, truncating) + Timeline/Nodes jump buttons + ✕ close, laid out with
egui layouts so they inherit the touch sizing and theme visuals instead of being
hand-painted.
Add Theme::var(name, ctx) (CSS custom-property getter) and Theme::apply_to_egui(ctx)
which maps the palette onto egui's global Visuals so standard widgets share the
theme colors. Add a mobile::palette::Palette built from the theme each frame and
replace the duplicated per-file C_* color constants across all mobile modules
(omni, topbar, stack, inspector, transport, intent, shell) with it. Add --scrim
and --accent-* category vars to styles.css. Apply the theme visuals every frame in
update(). Mobile and the main UI now share one CSS-variable-driven palette that
responds to light/dark/user themes.
Add mobile::dialog_width(ctx, desired) — a min()-clamp that's a no-op on wide
desktop screens. Clamp the Export and Export Progress modals and the Sample
Import window to it. Render Preferences as an egui::Modal (dim backdrop, centered,
no title bar) with a screen-fit scroll height when mobile_active(); desktop keeps
its window. Extracted the preferences body into render_body() shared by both paths.
Reserve a slim top bar in the mobile shell showing the project filename (or
"Lightningbeam" when unsaved) on the left, and ⌕/⋯ on the right. ⋯ opens a
curated overflow sheet (Save/Save As/Open/New/Import/Export/Undo/Redo/
Preferences); ⌕ opens a command palette that searches the whole menu tree
(flattened from MenuItemDef::menu_structure()). Both dispatch MenuActions.
Completes the P4 omnibutton + global menu.
Omnibutton (mobile/omni.rs): a context FAB whose radial holds the active layer's
drawing tools laid out in a staggered inner/outer arc, plus a "+New" petal
(create Vector/Audio/MIDI/Raster/Video layer, Group, Import, and Split/Duplicate
when a clip is selected — dispatched as MenuActions) and a "more" petal that
opens a full-tools grid when the layer has extra tools. Contextual: the tool ring
only appears when the Stage is in the visible window; off-Stage the FAB is a
"+New" create button. Tools/creates use Lucide icons. Pane-internal adds
(nodes/notes/instruments) deferred to P6.
Playback fix: document.current_time (read by the renderer for animation eval and
video-frame decode) was only synced from the audio playback position inside
TimelinePane::render_content. In the mobile shell, panes render on demand, so with
the timeline off-screen current_time went stale and the Stage froze during
playback. Move the sync into the per-frame block in update() so it runs regardless
of which panes are visible.
- Add `is_mobile` to SharedPaneState (set in build_frame). On mobile the timeline
layer-header column collapses to a minimal type-colored swatch (1:2 w:h, ~30px),
brighter/outlined when active/selected; tapping it selects the layer (handled by
the existing header-column input path, which now uses the narrower width).
Desktop headers unchanged.
- Inspector reflow: the sheet overlaps the stack by default, but when the selected
pane would be covered by the sheet, the stack reflows (renders above the sheet)
so it stays visible. Restore on dismiss is automatic since reflow only changes
the render rect; manual divider moves while the sheet is up persist. Adds
stack::pane_bottom_in and inspector::target_slot.
- New mobile/inspector.rs: when something is selected/focused, a bottom sheet
overlays the lower stack (no reflow) above the transport, showing the object's
properties by reusing InfopanelPane full-bleed. Header shows a title + ✕ to
deselect; jump-to chips (Timeline, Nodes) reframe the stack to that surface
with the selection kept. Grab handle resizes the sheet.
- Make the Infopanel Layer section editable (benefits desktop too): Name (text,
commit on Enter/blur), Opacity and Volume sliders (live preview, single-undo
on release), and Mute/Solo/Lock toggles. Backs the plan to reduce mobile layer
headers to a minimal swatch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When LB_MOBILE_UI is set, the desktop start screen is replaced by a phone
intent picker: a 2x3 grid of icon cards (Draw / Animate / Compose / Record /
Edit video / Blank) taking the top ~2/3, and a scrollable Recent-projects list
in the bottom ~1/3.
Each intent reuses EditorApp::create_new_project_with_focus and then sets the
initial stack window (e.g. Draw -> fullscreen Stage, Compose -> Timeline +
Piano Roll + Keys). Recent rows load the project and enter the editor. Cards are
icon-only (Lucide glyph + label) for legibility on a phone. Adds the intent
icons to the Lucide set.
Rework 3-pane divider behavior and animate every layout change:
- All transitions now ease over ~100ms via a LayoutAnim between two full stack
configs (resize, slide, collapse, fullscreen) instead of jumping; edge
transitions continue from the drag's progress.
- 3-pane dividers are linked along a single path parameter through the three
presets ([.5,.25,.25] / even / [.25,.25,.5]) so both dividers move together
and reach their snaps simultaneously. Over-grow past the extreme preset is
anchored to the path endpoint so the non-dragged divider stays continuous.
- Growing a pane past ~66% collapses the window to 2 panes (dropping the
opposite-end pane); the surviving divider snaps to the nearest 2-pane preset
(1/4, 1/2, 3/4) based on the drop position. Dragging to the slide extreme
still slides the window.
- 2-pane snaps to 1/4, 1/2, 3/4.
After releasing a divider, the boundary now eases (cubic ease-out, ~100ms) from
where the finger lifted to the snapped fraction instead of jumping. Starting a
new drag cancels an in-flight ease; membership changes still apply instantly.
Dragging a divider now resizes its two adjacent panes live and, on release,
snaps the boundary to the nearest of 1/4, 1/3, 1/2, 2/3, 3/4 — so one pane can
be made dominant within a 2- or 3-pane window. Dragging far enough to squeeze a
pane past the collapse threshold still triggers the membership transition
(slide/shrink per R1-R6), after which sizing resets to even.
MobileState gains a `weights: [f32;3]` (normalized on use); config_rects is
weight-aware; edge drags keep animating toward the even-split target.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bundle Lucide (ISC license) at assets/fonts/lucide.ttf and register it as the
egui font family "lucide" via mobile/icons.rs::install (called in EditorApp::new).
Icons are drawn as text with icons::font(size) + PUA codepoint constants.
Switch the stack headers (grip, maximize/minimize, node⇄instrument), the footer
chevron, and the transport play/pause to Lucide glyphs instead of relying on
egui's limited built-in emoji-icon-font.
- Double header height (52px) so the drag targets are easy to grab; footer
stays compact (28px).
- Add a fullscreen button on the right of each header that collapses the
window to that single pane; dragging the sole pane's header down or the
footer up splits it with the adjacent pane (count can now be 1). The button
toggles back to a 2-pane split.
- Use egui's bundled emoji-icon-font glyphs (⛶ fullscreen / ▣ restore) instead
of hand-drawn primitives.
- Cap the drag-to-commit distance (TRIGGER_MAX) so transitions don't require
dragging half the screen.
- Fix panic "attempt to subtract with overflow" when sliding to the end:
the R-ops used then_some(), which eagerly evaluates top-1 even when the
guard is false. Switch to lazy .then(|| ...).
- Replace the thin divider/edge grab bars with full-width header bars: each
band has a labeled header (top of band) that is its drag handle, plus a
reserved bottom footer for the BottomEdge "pull up for more" handle. Band 0's
header = TopEdge, band i's header = the divider above it, footer = BottomEdge.
The Node/Instrument ⇄ toggle lives in that band's header.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per design feedback, drop the top-tabs + single-ribbon model. The mobile UI
is now one vertical stack of all panes in a fixed order (Outliner, Asset
Library, Stage, Timeline, Piano Roll, Virtual Piano, Node/Instrument, Script
editor) with a window of 2-3 consecutive panes visible at a time.
Dragging the dividers between panes and the top/bottom screen edges grows,
shrinks, or slides the window via a small state machine (R1-R6 in stack.rs),
with continuous interpolation during the drag and snap-on-release. The
Node/Instrument band toggles between the node editor and the preset browser.
The transport stays pinned as the bottom floor.
Each visible band reuses PaneInstance::render_content full-bleed (surface.rs).
Removes topbar.rs and ribbon.rs; rewrites mod.rs state (window_top,
window_count, show_instruments, drag); adds stack.rs.
New src/mobile/ module renders a phone-style shell: top surface tabs
(Stage/Time/Nodes/Mixer/Tree), a single full-bleed hero pane reusing the
existing PaneInstance renderers, a resizable timeline ribbon (peek/half/full
snap tiers riding above the floor), and a fixed transport bar wired to the
audio controller (play/pause, timecode, project scrub).
Gated entirely on the LB_MOBILE_UI env var: when set, main() opens a
phone-aspect window and update() routes the central panel through
render_mobile_shell instead of render_layout_node, sharing the same
build_frame context and post-render drain. Desktop is unchanged when unset.
Tabs mirror the pane list; per the wireframe the Toolbar and Infopanel do
not become tabs (they become the omnibutton and inspector in later phases),
and the Nodes surface is a placeholder pending the focus/patch rework.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the ~80-field SharedPaneState/RenderContext literal from update()
into EditorApp::build_frame, returning a FrameBundle that also carries the
layout-editing borrows (current_layout, drag_state, etc.). Per-frame locals
move into a FrameScratch struct so the bundle can borrow both self and the
scratch under one lifetime.
This is a behavior-preserving refactor that gives the upcoming mobile UI
path a single shared construction site for SharedPaneState instead of
duplicating the literal.
Introduce editable text layers: a resizable text box with editable text,
font size, color, font family, and alignment.
Core:
- New TextLayer/TextContent (text_layer.rs), wired into AnyLayer/LayerType
and all the exhaustive match sites; structured so content can be keyframed
later via content_at().
- fonts.rs: thread-local parley FontContext with three bundled fonts
(Liberation Sans/Serif/Mono, SIL OFL), system-font enumeration consolidated
to base families, document-embedded fonts, glyph/caret/selection geometry,
and a background preloader for the picker fonts.
- Rendering via parley layout + Scene::draw_glyphs (renderer.rs); text
composites through the vector path.
- Actions: CreateTextClipAction (vector-layer branch, undoable),
SetTextContentAction, ResizeTextBoxAction.
- .beam font embedding: MediaKind::Font rows (content-hash dedupe) written on
save and registered on load, with bundled-default fallback.
- VectorClip content bounds include text boxes so text-only clips are
selectable/draggable.
Editor:
- Text tool: click empty/raster/video to create a top-level text layer, or a
vector layer to create+enter a clip containing the text; click an existing
box to edit it.
- Hybrid in-place editing: a hidden egui TextEdit drives input/IME/caret while
the text and caret/selection render in Vello; empty just-created layers are
removed on commit.
- Selection outline + 8 resize handles (re-wrap text) with hover cursors;
factored the corner/edge resize-cursor mapping shared with the Transform tool.
- Info panel: edit text, size, color, alignment, box size, and a font-family
picker that previews each entry in its own font (fonts preloaded in the
background to avoid hitches).
Deps: add parley (git, pinned to match vello's peniko); bundle Liberation
fonts under lightningbeam-core/assets/fonts. Gitignore the local
.cargo/config.toml used to select a machine's ffmpeg.
- Wire MenuAction::ToggleLayerVisibility (was a stub) to flip the active
layer's `visible` flag via SetLayerPropertiesAction (undoable). This is
what the SVG-export visibility fix depends on, and the "Hide/Show Layer"
menu item now works.
- Log the resolved color range in the zero-copy H.264 path to diagnose
whether `full_range` reaches the encoder (the VAAPI driver may still omit
the SPS VUI full_range flag).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Export correctness:
- Honor the user's color-range (Limited/Full) on the software encode path:
thread full_range through gpu_yuv (new shader range uniform), the CPU
swscale fallback (sws_setColorspaceDetails → BT.709 + range, fixing the
BT.601 hue/level shift on odd-width exports), and the encoder color tags.
- Reject HDR + WebM up front with a clear message and log the forced HEVC
override instead of producing an unplayable file.
- Delete dead render_frame_to_rgba_hdr (hardcoded Stretch; live HDR path
already honors the fit mode).
Decode/playback (video.rs):
- Drain the decoder at EOF (send_eof + flush) so the final B-frame-delayed
frames render instead of erroring; per-frame logic extracted to a helper.
- Missing-PTS frames continue monotonically rather than snapping to ts=0.
- Force exact thumbnail width so sub-128px sources aren't shown stretched.
Resource leaks (gpu-video-encoder):
- dmabuf import_raw: RAII guard frees the duped fd + partial VkImages/memory
on every error path.
- vaapi alloc: free device/frames-ctx/AVFrames on the unexpected-DRM path.
Data model / robustness:
- collapse_boundary_spikes requires a full curve reversal (all control
points) so it no longer deletes a real lens/sliver and drops the fill.
- Export audio spin-wait ignores a stale `finished` flag when a forward
seek is pending (was rendering silence over real audio).
- RasterDiff apply_before/after take current dims and skip on a post-resize
mismatch.
- beam_archive read_media_full caps the preallocation from untrusted total_len.
UI/visual:
- SVG export skips hidden layers/empty groups; import folds fill-opacity into
gradients and surfaces failures as a notification.
- Active raster-layer border uses playback_time + overlay_transform.
- gpu_brush remove_layer_texture also evicts the stale low-res proxy.
- ensure_raster_resident_for_undo registers faulted frames in the LRU so
resident RAM stays bounded.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Export (lightningbeam-core/svg_export.rs): document_to_svg walks vector
layers/groups at a given frame and emits <path> per fill (solid or
gradient via <defs>) and per stroked edge. Wired into the export dialog
as an "SVG" tab; written synchronously. Raster/video/effect layers are
skipped (vector-only, lossless), structured for a later rasterize pass.
Import (lightningbeam-editor/svg_import.rs): import_svg parses via usvg,
bakes each path's absolute transform into geometry, converts segments to
cubic edges, and maps solid/linear/radial paint to ShapeColor/
ShapeGradient. .svg is detected in the Ctrl+I Import handler and added as
a new vector layer (keyframe at the playhead). file_types gains
FileType::Vector + VECTOR_EXTENSIONS.
Tests: svg_export::export_tests (core) and svg_import::tests (editor).
Resizing the document leaves raster layers as-is (canvas keeps its old pixel size,
anchored top-left). To reconcile, the info panel now shows the active raster
layer's canvas size — driven by the *active* layer, not selection focus, since
painting doesn't focus the layer — and, when it differs from the document, a
Scale / Expand-Crop toggle + a "Layer to document size" button.
- RasterKeyframe::resize_to(w, h, mode): always applies the new declared size;
resamples (Lanczos3) when Scale, top-left pad/trim when Canvas, and only touches
the buffer when pixels are resident (a blank canvas just takes the new size).
Sets texture_dirty so the stage's dirty-scan refreshes the GPU texture.
- ResizeRasterLayerAction resizes every keyframe with undo, holding a (read-only)
RasterStore so paged-out keyframes are loaded one at a time rather than bulk.
Resized keyframes stay resident + dirty and persist on the next full save (no
incremental store write to page them back out). Scale is lossy/compounds;
Expand-Crop is lossless.
Outline the active raster layer's canvas bounds (document space) so its size is
visible, especially when it differs from the document after a resize. Two strokes
sharing one dash pattern, the yellow offset by a dash to fill the black's gaps -
interlocking marching-ants. Sizes divided by zoom so the dashes stay ~constant on
screen.
ImageExportSettings gains `fit: ExportFitMode` (default Letterbox) + a "Fit"
dropdown in advanced image settings; the image render path uses it instead of the
hardcoded Letterbox. Image export supports a resolution override, so the mode
matters when the override aspect differs from the document.
A) Imported video had a different aspect ratio depending on how it was placed:
direct import used a uniform scale + center (preserve aspect), while the
asset-library timeline drag used an independent scale_x/scale_y (stretch to fill).
Add Transform::fit_centered (uniform scale, centered, aspect-preserving) and route
both paths through it, so a clip looks identical however it's added.
B) Exported video was stretched when the export resolution's aspect differed from
the document's (base_transform was always scale_non_uniform). Add
ExportFitMode {Stretch, Letterbox (default), Crop} on VideoExportSettings + a "Fit"
dropdown in advanced export settings, and a shared export_base_transform() helper:
Letterbox = uniform fit centered (black bars), Crop = uniform fill centered (trim),
Stretch = the old distort-to-fill. Threaded through the software, HDR, and
zero-copy export render paths (image export is doc-sized → identity).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two bugs made 4K (and any) video playback wrong:
1. Sped-up playback. The VideoManager frame cache was keyed by milliseconds, but
GPU (hardware-decoded) frames bypass the decoder's internal cache. A UI that
refreshes finer than the video frame rate (a 60Hz canvas on a 30fps clip)
therefore missed the cache on every sub-frame request and decoded the NEXT
frame each time — advancing the video ~2x faster than the clock, and racing the
decoder toward EOF. Key the cache on the video frame index (round(ts·fps))
instead, so all requests within one frame share an entry and each frame decodes
exactly once → correct speed.
2. Periodic seek/jerk. The seek decision compared the rounded request frame_ts to
the decoded frame's exact PTS, which sit on slightly different grids — so
frame_ts landed ~1 frame "behind" the just-decoded PTS every ~10 frames and
falsely read as a backward seek (40ms seek + GOP re-decode). Track the previous
request (last_requested_ts), which is strictly monotonic during forward
playback, and detect backward from that instead. Real scrubs still seek.
Per-frame HW decode is ~3-5ms; both bugs were spurious decode/seek work on top.
Keeps a gated LB_VIDEO_DEBUG [Video Seek?] diagnostic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The asset-library thumbnail called VideoManager::get_frame, which honors the
render-pass hardware flag — left ON by the preview, so the thumbnail got a GPU
frame with empty rgba_data → an all-black thumbnail. The thumbnail is also the
only consumer that requests a fixed low timestamp (1.0s) on the shared per-clip
decoder, so when it re-decodes during playback it yanks the decoder back to ~1s;
the next playback frame (6.x) is then ">2s forward" and re-seeks to the keyframe
+ re-decodes the whole GOP (the jerk: per-frame decode is ~5ms, but these seeks
cost 40ms + N-frame catch-up).
Add VideoManager::get_frame_cpu (forces want_gpu=false regardless of the render
flag); the thumbnail uses it. Now it produces a real RGBA thumbnail that the
editor texture-caches once, instead of re-decoding black frames.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Makes the common Linux H.264 export fully GPU-resident: decode (HW NV12) →
composite → VAAPI encode all on one device, no CPU round-trips.
- gpu-video-encoder: ZeroCopyEncoder now holds wgpu (device,queue,adapter) handles
instead of owning a DrmDevice. New `new_on_device(device,queue,adapter,...)` runs
the RGBA→NV12 render + DMA-BUF import on a passed device; `new` keeps building its
own. The encoder only *imports* VAAPI surfaces (not export), so the shared
import-capable device works directly.
- editor: stash the shared device handles on EditorApp (set in the creation closure
from the eframe render_state when the shared device is active) and thread them
through start_video_export / start_video_with_audio_export → try_build_zero_copy,
which uses new_on_device when available. ZeroCopyVideo carries on_shared_device →
the export composite uses hardware_ok=true (consuming HW-decoded GPU frames from
pt 1) only then.
Falls back to the own-device encoder (decode downloads to CPU) when no shared
device. Tradeoff: the export now shares the GPU with the UI thread (was a separate
device); acceptable for the GPU-resident-decode win. Compiles; crate tests build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The software and HDR export paths already composite on the shared device (the
eframe device, via render_next_video_frame), so they can consume the same
hardware-decoded NV12 GPU frames the preview uses — no CPU download/upload:
- ExportGpuResources gains the Nv12BlitPipeline; the export compositor's Video arm
branches on inst.gpu (NV12 blit, mirroring stage.rs) vs the RGBA upload path.
- render_frame_to_gpu_rgba takes a hardware_ok flag: true for the software video +
image export (shared device), false for the zero-copy path (own device, must
download). render_frame_to_yuv10_hdr sets it true.
Drops the 4K software/HDR-export decode wall (HW decode + GPU composite, no
per-frame RGBA upload). The common Linux H.264 zero-copy path is unchanged
(separate device) — that's pt 2. Falls back to software when no shared device /
importer (flag is harmless then: get_frame returns CPU anyway). Compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire HDR frame production end-to-end (encoder side was pt 1):
- linear_to_pq.wgsl: linear scene HDR (BT.709, white=1.0) → BT.2020 primaries →
PQ (203-nit) or HLG OETF → gamma-encoded R'G'B'. Inverse of the nv12 decode, so
a decode→encode round-trip is the identity.
- hdr_frame.rs (isolated module): runs that pass into an Rgba16Float target, does a
synchronous readback (f16-decoded on CPU), then BT.2020 R'G'B'→Y'CbCr limited
4:2:0 10-bit pack → YUV420P10LE planes. Rgba16Float avoids the 16BIT_NORM device
feature dependency.
- video_exporter::render_frame_to_yuv10_hdr: composite + HDR encode, returning
10-bit planes; lazily builds the pipeline.
- orchestrator: HDR uses a synchronous 1-frame-per-call path (the async RGBA
pipeline is 8-bit only); zero-copy is skipped for HDR.
- dialog: "Dynamic range" dropdown (SDR / HDR10 PQ / HLG).
Software HEVC Main10 path; favors correctness over throughput. Compiles; runtime
verification needs a GPU + HDR-capable player.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings + encoder side of HDR video export (frame data still SDR until pt 2):
- core: HdrExportMode {Sdr (default), Pq, Hlg} on VideoExportSettings (serde
default), with transfer-name helpers.
- setup_video_encoder takes the mode: HDR → YUV420P10LE, BT.2020 NCL matrix,
limited range, color_primaries=bt2020, color_trc=smpte2084/arib-std-b67,
profile=main10. SDR path unchanged (8-bit full-range BT.709).
- run_video_encoder forces HEVC when HDR is selected (the only 10-bit codec wired
up). encode_frame is parameterized by pixel format and now copies planes
row-by-row honoring stride (10-bit / non-aligned widths can have row padding).
Dormant: no UI exposes the mode yet and the frame data is still 8-bit SDR, so
selecting HDR is not yet wired. The render→PQ/HLG 10-bit frame path is pt 2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>