diff --git a/Changelog.md b/Changelog.md index 4af7dbd..2924498 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,29 @@ +# 1.0.5-alpha: +Changes: +- Add shape tweens (morph vector geometry between keyframes) +- Add motion tweens for groups and movie clips +- Group geometry and Convert to Movie Clip now work on DCEL vector shapes +- Region/lasso select now cuts the shape and feeds the normal selection, so Group, Convert, Delete and Properties all work from a lasso (hold shift to add to the selection) +- Clip instances now draw on top of a layer's loose shapes +- Add onion skinning for raster and vector layers, with tinted ghosts and settings in the Info Panel +- Images can now fill vector shapes (None / Solid / Gradient / Image fill types) +- Imported images can be placed on the canvas +- Add a raster keyframe timeline UI with explicit keyframe creation; click a keyframe diamond to snap the playhead to it +- Stream audio, video and images to and from the project file instead of holding them in memory, supporting arbitrarily long media +- Persist (and resume) waveforms and video thumbnails in the project file +- Use low-res proxies for fast cold scrubbing of raster frames +- Bound memory use for raster pixels, GPU textures, video frames and decoded images on large projects +- Video export is roughly 4x faster +- Downmix surround video audio to stereo + +Bugfixes: +- Fix video export resolution scaling and a post-export UI hang +- Fix gamma handling and improve brush canvas performance +- Fix a save crash on projects with zero or sparse audio +- Fix raster strokes vanishing when committed +- Fix image fill mapping (anchor to the fill's bounding box) +- Fix video thumbnail strip bugs + # 1.0.4-alpha: Changes: - Beats are now the canonical time representation (replacing seconds) diff --git a/STREAMING_TO_DISK_PLAN.md b/STREAMING_TO_DISK_PLAN.md new file mode 100644 index 0000000..2a43ebb --- /dev/null +++ b/STREAMING_TO_DISK_PLAN.md @@ -0,0 +1,853 @@ +# Streaming Media To/From Disk — Plan + +**Goal:** Lightningbeam must handle audio and video files (and raster animation, and +image assets) of *arbitrary length/size*. Anywhere we touch media we should stream from +and to disk when the data is too large to fit comfortably in memory, rather than loading +the entire file regardless of size. + +**Scope of this document:** audio, video, raster frames, image-asset paging, **and the +`.beam` container format** — these turned out to be one problem, not two. Streaming on load +is impossible while the container forces a full decode, so the container decision (below) +is now part of this plan. + +## Deferred bugs (do at the end) +- [x] **Timeline thumbnail scroll (FIXED):** the strip tiled from the *clamped* visible-left of the + clip, so when a clip was scrolled partly off the left it showed the clip's start content at the + viewport edge. Now tiled from the clip's **true (unclamped) origin** over its full width, drawing + only the tiles intersecting the visible rect (`draw_video_thumbnail_strip` in timeline.rs). Both + render sites (collapsed-group + expanded-track) share the helper. *(Compiles; needs in-app check.)* +- [x] **Clip thumbnails stop updating (FIXED):** the GPU texture cache was keyed by the *requested* + content time, so once a tile cached the first (often far-off) thumbnail it never refreshed as + closer ones loaded. `VideoManager::get_thumbnail_at` now also returns the **actual** thumbnail + timestamp, and the cache keys on that — so a tile picks up a new texture when a closer thumbnail + finishes generating. Existing `retain`-by-visible-clip cleanup keeps it bounded. *(Needs in-app check.)* + +## Raster-keyframe-UI bugs — **[DONE]** (built the raster keyframe timeline UI, 2026-06-20) +Both resolved by the raster-keyframe-timeline-UI work: timeline now draws a diamond per +`RasterKeyframe` (mirrors vector), `K`/New Keyframe inserts a blank cel via `AddRasterKeyframeAction` +(canvas refreshes), paint tools edit the active keyframe instead of lazily creating, diamonds are +click-to-seek (pointing-hand cursor), playback prefetches frames, and onion skinning (raster+vector, +tinted, Info-Panel settings) is in. (a) canvas-refresh-on-new-keyframe and (b) keyframes-on-timeline +are both fixed. + +## Noted enhancements (later, after the phases) +- [x] **Surround → stereo downmix (DONE).** Done uniformly in `render_from_file` (`pool.rs`) so it + covers every storage type (PCM/InMemory, compressed via symphonia, video-audio via ffmpeg — all + flow through this mixer with the source kept multichannel in the read-ahead buffer). New + `stereo_downmix_matrix(src_channels)` gives `[L][src]`/`[R][src]` coefficients for the conventional + interleave order (FL FR FC LFE BL BR SL SR…) for 3/4/5/5.1/6.1/7.1: full level for the matching + front, `1/√2` for centre + each surround, LFE dropped; each row normalized so |coef| sum ≤ 1 to + prevent clipping (matches ffmpeg's default). Applied in both the direct-copy and sinc-resample + paths (only when `dst==2 && src>2`; unknown layouts fall back to front L/R). Compiles clean. + *(Needs in-app check: a 5.1 file now has centre/dialog present and isn't thin; not distorted/clipping.)* + Native multichannel support remains a separate, larger project. +- **Export speed (audited 2026-06-21):** a 1:14 1080p MP4 took ~9:06 (~7.4x realtime, ~135 ms/frame). + Audit **refuted** the per-frame-seek theory — export decodes the source *sequentially* + (`video.rs` `need_seek` is false once advancing forward), and readback is already async + + triple-buffered. Real hotspots: + - **[DONE] #1 — per-frame renderer rebuild.** The export pump built a fresh `vello::Renderer` + (full wgpu pipeline init) + empty `ImageCache` *every egui repaint* (`main.rs` ~6218). Now built + once per export and reused; also fixed lazy-image export (the throwaway cache had no container + path). **Expected the dominant win.** + - **[DONE] #2a — encode swscale rebuilt per frame.** `CpuYuvConverter::convert` now caches the + RGBA→YUV420p `scaling::Context` + frames in `new()` instead of per call. + - **[TODO] #2b — decode swscale + stride-repack** per frame in `video.rs:294-320` (shared with + scrubbing; cache the YUV→RGBA scaler on the decoder). Small win, modest risk. + - **Result of #1+#2a (measured):** ~7.4x → **~1.74x realtime** (130.7 s for 4488 frames @ 60 fps; + 34 fps). Per-stage avg: Render(CPU build) 15 ms, **Readback(GPU latency) 42 ms**, Extract 1.3 ms, + Convert 5.7 ms. + - **Now GPU-bound.** Per ~87 ms poll cycle the CPU does ~66 ms (3× build 45 + convert 17 + extract 4) + but the GPU does ~87 ms (3 × ~29 ms composite) → GPU saturated at ~29 ms/frame; "Readback 42 ms" is + queue latency, not transfer (8 MB is sub-ms). + - **[SKIP] #3 GPU YUV / #5 pacing** — both only trim the CPU side, which is already *under* the GPU. + Won't move a GPU-bound throughput. + - **[TODO, big] Reduce the GPU composite (~29 ms/frame).** The per-layer HDR pipeline (Vello render → + linear → composite, ×layers) is the wall, shared with live rendering. Options: batch composite + passes; a fast-path skipping HDR compositing for simple single-layer/no-blend docs; cache unchanged + layers' scenes (CPU-side, only helps if it later becomes CPU-bound). Render-architecture project. + - Non-issues: per-frame seek, blocking readback, audio. (`video.rs:237` container-reopen-on-seek is + a latent cost but doesn't fire on forward export.) +- **AAC export NaN guard (done):** `convert_chunk_to_planar_f32` now sanitizes non-finite samples + (NaN/Inf → 0, finite clamped to [-1,1]) like the integer paths, with a one-time warning — a stray + non-finite render sample no longer fails the whole export. Upstream NaN source (effect/automation/ + decode) still worth chasing if it recurs. +- [x] **Persist video thumbnails (DONE).** Mirrors waveform persistence: each clip's thumbnails are + PNG-encoded + packed into one opaque `LBTN` blob (editor owns the format; `encode/decode_thumbnail_blob` + in main.rs), stored as a `MediaKind::Thumbnail` row keyed by `thumbnail_media_id(clip_id)` (clip id XOR + a fixed sentinel). Save: a cheap Arc-clone snapshot (`VideoManager::snapshot_all_thumbnails`) rides the + `FileCommand::Save`, PNG-encoded off the UI thread in the worker, written by `save_beam` (kept in place + on re-save). Load: `load_beam_sqlite` reads the packs into `LoadedProject.thumbnail_blobs`; the editor + decodes + `insert_thumbnail`s them on a background thread and **gates regeneration** (`register_loaded_videos` + skips clips with persisted thumbnails). Bonus: thumbnails show even if the source video file is missing. + **Partial sets are persisted and resumed** (not thrown away): the `LBTN` blob (v2) carries a `complete` + flag (`VideoManager.thumbnails_complete`, marked when the keyframe pass finishes). On load, complete + packs are restored + skip regeneration; *partial* packs are restored AND generation is resumed — + `generate_keyframe_thumbnails` takes a `should_skip` predicate (`has_thumbnail_near`) so it only decodes + the keyframes not already covered. `insert_thumbnail` is now sorted + idempotent (fixes a latent + unsorted-`binary_search` bug and makes concurrent restore + resume race-safe). So a save 50 min into a + 2 h video keeps that work and continues from there on reload. + Container tests still green; all crates compile. *(Needs in-app check: reload = instant thumbnails for + complete clips; a mid-generation save resumes from where it left off on reload.)* + **Size assessment (done):** thumbnails are 128px wide, height by aspect (72px at 16:9 → + 128×72×4 ≈ **36 KB raw** each; 4:3 ≈ 49 KB), generated **one per ~5 s** (capped `interval_secs`, + at keyframes — so ~12/min). Raw: ~0.5 MB per 1:14 clip, ~26 MB/hour, ~52 MB/2 h. Compressed for + on-disk: JPEG ~3–6 KB/thumb → **~6 MB/2 h**; PNG ~8–15 KB → ~14 MB/2 h. So persistence is cheap + (≤ the waveform's ~36 MB/2 h), especially as JPEG. Plan: encode each clip's thumbnails (JPEG) + + their timestamps into one blob, a new `MediaKind::Thumbnail` row keyed by the clip/media id (mirror + the waveform persistence: write on save, restore via `insert_thumbnail` on load, regenerate if + absent). The 5 s interval already bounds count; no extra budget needed. +- **Progressive waveform on first import:** generation streams the whole file before the + waveform appears (several seconds for large files). Since `build_waveform_pyramid` already + streams, emit partial floors as it advances (e.g. flush every N seconds of decoded audio via + the existing `waveform_result` channel + chunked GPU upload) so the overview fills in across + the clip left-to-right instead of appearing all at once. Persistence saves only the final + complete pyramid. + +## Guiding principle +Three subsystems already have the right streaming primitive; most of the work is wiring, +bounding caches, and adding a residency window. The recurring pattern: + +> Keep tiny metadata always-resident, fault the heavy payload in on demand keyed by a +> stable ID, and evict everything outside a window around the playhead. + +--- + +## Audit summary (where we stand today) + +### Correctly streaming / bounded +- Video frame decode/seek/playback (`lightningbeam-core/src/video.rs:191` `get_frame` — + keyframe-index seek + decode-until-target, one frame resident). +- WAV/AIFF import via mmap (`daw-backend/src/audio/engine.rs:2328`). +- Webcam capture encodes directly to disk (`lightningbeam-core/src/webcam.rs`). +- `WaveformCache` (100MB cap), decoder `LruCache` (20 frames), export render loop (≤3 + frames in flight). +- The compressed-audio disk reader `daw-backend/src/audio/disk_reader.rs` + (`CompressedReader` + 3s `ReadAheadBuffer`) — **correct but never activated** (Phase 1a). + +### Fully-loaded, unbounded by file length (the problems) +| Site | Issue | +|---|---| +| `daw-backend/src/io/audio_file.rs:344` `decode_progressive` | Decodes whole compressed file into a `Vec`; de-facto playback source. | +| `daw-backend/src/audio/pool.rs:1071` `load_file_into_pool` | Every audio file in a saved project fully decoded to `InMemory` on open. | +| `lightningbeam-core/src/video.rs:711` `extract_audio_from_video` | Whole video audio track into one `Vec`. | +| `lightningbeam-core/src/video.rs:412` `VideoManager.frame_cache` | Unbounded `HashMap` of full-res RGBA frames; grows while scrubbing. | +| `export/mod.rs:388-400` | Mux step buffers all compressed packets into `Vec`s; O(duration). | +| `lightningbeam-core/src/raster_layer.rs:115` `RasterKeyframe.raw_pixels` | ~8MB/frame at 1080p; all keyframes decoded from PNG at load (`file_io.rs:611-640`), never evicted. | +| `lightningbeam-editor/src/gpu_brush.rs:1051` `raster_layer_cache` | Unbounded GPU texture `HashMap`. | +| `lightningbeam-core/src/renderer.rs:25` `ImageCache` | Unbounded decoded image cache (asset textures). | +| `Document.image_assets` (`document.rs:206`) | Every image asset's compressed bytes resident for document life. | + +--- + +## Container format decision: `.beam` → SQLite *(DECIDED)* + +The `.beam` container moves from a **ZIP archive** to a **SQLite database file** (same +`.beam` extension). This is the foundation the rest of the plan builds on. + +### Why +ZIP can stream `Stored` entries in place (via `data_start()`), but it has **no in-place +mutation** — every save and every raster frame write-back rewrites the whole archive — and +embedded PCM is rarely mmap-aligned. The current load path is even worse: it reads each +ZIP audio entry fully, decodes FLAC → re-encodes WAV → base64 → base64-decodes → temp file +→ full Symphonia decode → resident `Vec` (`file_io.rs:513-604`, `pool.rs:1071`). + +SQLite dissolves the single-file-vs-performance tension: +- **Single file** — beginner-friendly, behaves like a file on every OS (no package-folder + confusion; we have no bundle magic on Linux/Windows). +- **Streaming reads** — `sqlite3_blob_open` / `blob_read(offset, len)` gives seekable, + chunked reads through the pager (mmap mode for the DB). For chunked streaming the + pager-copy is negligible vs. decode cost, so the lack of zero-copy mmap doesn't matter. +- **Cheap, crash-safe mutation** — raster frame write-back is a transactional `UPDATE`; + save is a metadata write + dirty-blob updates. **ACID** means a force-quit / power loss / + crash mid-save can't corrupt the project (ZIP and package-dirs both have to hand-roll + atomicity). +- **Inspectable / scriptable** — `sqlite3` CLI; `beam_inspector.py` can read it directly. + +**Net effect: there is no scratch directory anywhere in this plan.** Media stream via blob +reads (or external paths); raster frames live in blob rows and write back transactionally. + +### Large-media policy: packed OR referenced +Two storage modes per media item, both supported: +- **Packed** — bytes live in the DB. To stay under SQLite's ~2GB per-blob ceiling (and to + make reads naturally chunked), large media is split into **multiple blob-chunk rows** + (e.g. 64 MB/chunk); streaming reads address `(chunk_index, offset)`. +- **Referenced** — the DB stores only a path; bytes stay on disk (useful for shared media + on a network drive, or media too large/volatile to pack). + +**Default-mode preference for files over the per-blob limit (~2GB):** +- A user preference `large_media_default: Pack | Reference` controls what happens to + imports above the threshold. +- The **first time** the user imports a media file over the limit, **prompt** them + (Pack vs Reference), apply it, and **persist the choice** as the preference for future + large imports (changeable later in settings). +- Files under the limit are packed by default (chunked only if needed). + +### Schema sketch +``` +media( + id BLOB PRIMARY KEY, -- stable Uuid + kind INTEGER, -- audio | video | raster | image-asset + codec TEXT, -- "flac","mp3","png",... (original, lossless-preserving) + storage INTEGER, -- 0 = packed, 1 = referenced + ext_path TEXT, -- set when storage = referenced + total_len INTEGER, -- bytes (packed) for chunk math + channels INTEGER, sample_rate INTEGER, width INTEGER, height INTEGER -- kind-specific meta +) +media_chunk( + media_id BLOB, chunk_index INTEGER, bytes BLOB, + PRIMARY KEY (media_id, chunk_index) +) +project_json(id INTEGER PRIMARY KEY CHECK (id = 0), data TEXT) -- existing project.json, verbatim +meta(key TEXT PRIMARY KEY, value TEXT) -- version, created, modified +``` +`project.json` stays the same serialized `BeamProject` for now — only its container and the +media storage change. A migration reads a legacy ZIP `.beam` and writes the SQLite form on +first open/save. + +### Streaming reads from packed media +A `BlobReader` implementing `Read + Seek` over `media_chunk` rows feeds the existing +streaming consumers unchanged: `CompressedReader` (audio) decodes from it instead of a +`File`; the video decoder seeks within it; raster `UPDATE`s a chunk. Referenced media uses a +plain `File` exactly as `do_import_audio` already does for originals today. + +--- + +## Phase 1 — Audio: activate what already exists *(highest impact, lowest effort)* + +### 1a. Turn on the compressed-audio disk reader +The `CompressedReader` + 3-second `ReadAheadBuffer` in `disk_reader.rs` is complete but +never invoked (`DiskReaderCommand::ActivateFile` / `DiskReader::create_buffer` are never +called; `AudioClip::read_ahead` at `clip.rs:63` is hard-wired to `None`). +- On compressed import (`engine.rs:2381`) and during playback setup, activate the file and + assign `AudioClip::read_ahead`. +- Change `decode_progressive` (`io/audio_file.rs:344`) to produce only the downsampled + waveform overview (min/max peaks) the UI needs, then drop decoded PCM. Playback comes + from the ring buffer, not RAM. +- Verify `render_from_file` (`pool.rs:449`) reads from `read_ahead` when `data()` is empty. + +**Risk:** the real-time thread must never block on disk. The ring buffer prefetches ~2s +ahead; underruns degrade to silence (live) or block-wait (export), which `disk_reader.rs` +already distinguishes. + +### 1b. Stream on project load *(depends on the SQLite container)* +Three coupled changes (none works alone): +1. Replace `load_file_into_pool`'s full decode (`pool.rs:1071`) with the same branching as + `do_import_audio`: PCM → mmap (referenced) or in-memory for tiny packed PCM; compressed + (incl. FLAC) → `from_compressed` placeholder backed by a `BlobReader` (packed) or `File` + (referenced). The claxon FLAC→WAV→base64 round-trip in `file_io.rs:533-591` is deleted. +2. **Bulk read-ahead activation:** loaded clips are deserialized directly + (`audio_backend.project`), bypassing `AddAudioClip`, so the Phase 1a wiring never fires + for them. After the engine installs the project, walk all audio clips and + `create_buffer` + `ActivateFile` + set `read_ahead` for every clip referencing a + `Compressed` pool entry. (`CompressedReader::open` needs a variant that takes a + `BlobReader` instead of a path for packed media.) +3. Pool entries carry storage mode (packed-chunks vs referenced path) from the `media` + table instead of base64 `embedded_data`. + +### 1c. Video's embedded audio track — stream from the video via ffmpeg + +**Interim stopgap (shipped):** `extract_audio_from_video_to_wav` streams the decoded audio to +a temp WAV, imported via `import_audio_sync` (mmap). Fixes the RAM OOM but writes the whole +uncompressed track to `/tmp` (fills small temp partitions) and the temp path doesn't survive +save/reload. **Superseded by the design below.** + +**Proper design — stream the video's audio track on demand, never materialized.** + +*Enabler:* `daw-backend` already depends on `ffmpeg-next` (used for MP3/AAC encoding), so the +ffmpeg audio decoder lives beside `CompressedReader` in `daw-backend/src/audio/`. No +cross-crate work (`core → daw-backend` is one-way). `CompressedReader` already has the needed +interface. + +1. **`VideoAudioReader` (ffmpeg)** — mirrors `CompressedReader`: + `open(path)`, `decode_next(&mut Vec) -> frames` (resample → interleaved f32 at native + rate; reuse the old extraction resampler), `seek(target_frame) -> actual`, + `sample_rate`/`channels`/`total_frames`. +2. **Source dispatch:** `enum StreamSource { Compressed(CompressedReader), Video(VideoAudioReader) }` + (or a small `trait AudioFrameSource`) held by the reader thread; ring buffer / prefetch / + export-blocking unchanged. `DiskReaderCommand::ActivateFile` gains a `kind: SourceKind`. +3. **Pool model:** `AudioStorage::VideoAudio { video_path, decoded_for_waveform, decoded_frames, + total_frames }` (near-copy of `Compressed`); `data()` empty, playback via `read_ahead`. Pool + entry `path` = the video file. +4. **Engine API:** `EngineController::add_video_audio_sync(video_path) -> usize` — ffmpeg-probe + the audio track (rate/channels/frames/duration, no decode), build the pool entry, return index. +5. **Clip activation:** extend the Phase 1a `AddAudioClip` wiring — if entry is `VideoAudio`, + make the buffer + `ActivateFile{kind:VideoAudio, path:video_path}` + set `clip.read_ahead`. + One ffmpeg context + 3 s buffer per active clip instance. +6. **Import flow:** `import_video` calls `add_video_audio_sync(video_path)` → + `AudioClip::new_sampled`. **Remove** `extract_audio_from_video_to_wav`, the temp-WAV + handling, and the now-dead `add_audio_file_sync`. No WAV / `/tmp` / RAM. +7. **Save/load:** the `VideoAudio` entry serializes as a path reference to the video (no media + bytes — the video is already referenced by its `VideoClip`); reconstruct on load by + re-probing. Fixes the stopgap's reload fragility (nothing to persist). +8. **Waveform overview:** background ffmpeg pass emitting **downsampled peaks only** (bounded + memory) into the existing waveform path — shared with the Phase 1a `decode_progressive` + cleanup. + +**Sample accuracy (required — video audio must stay frame-synced with other clips):** +Coarse ffmpeg seeks are NOT sufficient. `VideoAudioReader::seek(target_frame)` must: +- coarse-seek to a point ≤ target, then **decode-and-discard** to land exactly on + `target_frame`, tracking the absolute sample position from decoded-frame PTS (discard whole + frames before target; for the frame straddling target, drop its leading samples). After + `seek`, `decode_next` yields samples starting at exactly `target_frame`. +- This makes frame N of the video-audio pool entry correspond to the exact timeline position, + so it mixes sample-aligned with mmap/InMemory clips. Continuous decode advances frame-exact. +- *Consistency note:* `CompressedReader` should get the same decode-discard alignment (its + current coarse-seek-then-write-at-target can misalign by up to a GOP after a seek). Fold in + while here, or at least flag. + +*Model decision (confirmed):* the video's audio stays a **separate, editable `AudioClip`** on +an audio track, backed by the `VideoAudio` pool entry — users can move/trim/mute/detach it. + +*Build order:* `VideoAudioReader` + `StreamSource` → pool `VideoAudio` variant → +`add_video_audio_sync` + activation → swap `import_video` (remove WAV path) → sample-accurate +seek (both readers) → waveform-peaks pass. + +--- + +## Phase 2 — Video: bound the caches *(small, isolated)* + +### 2a. Bound `VideoManager.frame_cache` +`video.rs:412` — convert the unbounded `HashMap<(Uuid,i64), Arc>` to an LRU +mirroring the decoder-level cache (`video.rs:34`). Frame-count or byte budget. + +### 2b. Stream the export mux +`export/mod.rs:388-400` — interleave-write packets to the output as produced (compare PTS, +write the earlier stream) instead of collecting all then writing. O(duration) → O(1). + +--- + +## Phase 3 — Raster: disk-backed keyframe paging *(the heavy one)* **[locked design]** + +Today `load_beam_sqlite` (`file_io.rs:564`) eagerly `decode_png`s **every** raster keyframe's +`Raster` media row into `RasterKeyframe.raw_pixels` (`raster_layer.rs:115`, `w·h·4` ≈ 8 MB @ +1080p, `#[serde(skip)]`), never evicts, has an unbounded GPU texture cache, and holds full-frame +undo snapshots. `raw_pixels` is the working rep (edits write it, save reads it, render reads it), +`has_pixels()` = `!raw_pixels.is_empty()`, `keyframe_at` is a `partition_point` binary search, and +the container is opened only at load/save (no live handle). + +**Design (confirmed with user):** keep `raw_pixels` as the working rep; make residency explicit +via a `RasterStore` + an editor-run fault-in/evict pass *before* the immutable render. Async +fault-in (no scrub hitch), with a **low-res image proxy** shown until the full frame lands. +Decisions: small window (±~2 keyframes); **dirty (edited-unsaved) frames stay fully resident** +(spill-to-scratch deferred); fault-in is **async**; proxy is a **per-keyframe low-res RGBA image** +(PNG/WebP, correct alpha), NOT a video (VP9-alpha was rejected as finicky for negligible disk win). + +### Drive-by (Arc pixels): DROPPED +Investigated and rejected: `raw_pixels` has ~64 access sites, and most `.clone()`s genuinely need +an owned `Vec` (undo buffers, export, GPU readback) so `Arc>` would force `(*p).clone()` +and still copy. The only beneficiary, the per-frame `renderer.rs:550` Vello clone, is on the +**legacy/dead** path — the live HDR canvas renders raster as `RenderedLayerType::Raster` → GPU +upload in `stage.rs` which passes a `&[u8]` slice and uploads only on cache-miss (no per-frame +clone). Not worth 64 edits. Start at 3a. + +### 3a. Lazy async fault-in + image proxy +- **[DONE 3a-1]** Lazy load: full-decode removed; `raw_pixels` empty on load, `needs_fault_in` + armed recursively; canvas records misses → App pages in via `RasterStore.load_pixels`. +- **[DONE 3a-2]** Async: page-in runs on a background thread (deduped via `raster_loads_inflight`); + results applied at top of `update()`. No UI block on cold scrub. +- **[DONE 3a-3]** Image proxy: `MediaKind::RasterProxy` (≤192px PNG, derived id), written + beside each resident full PNG on save + eager-decoded on load into `RasterKeyframe::proxy`. + Separate `proxy_layer_cache` (own LRU, budget 64); the raster render blits the proxy mapped to + the keyframe's FULL logical dims (upscales via sampler) when the full texture isn't resident. + *(Proxies exist only after a save+reload; eager decode → lazy/paged is a refinement for huge + paint projects.)* + +- **`RasterStore`** (core): current `.beam` path + a read-only connection; `load_pixels(kf_id,w,h)` + reads the `Raster` row and `decode_png`s it. Set/cleared by the editor on load + save-as. +- **Save:** alongside the full PNG, write a low-res RGBA proxy per resident keyframe + (`MediaKind::RasterProxy`, ≤~480px long edge, keyed by `kf.id`). +- **Load:** stop eager full-decode; decode **proxies** eagerly (cheap → instant scrub everywhere); + leave full `raw_pixels` empty. +- **Fault-in pass** (editor, `&mut document` + store, each frame before render): for each raster + layer ensure the active keyframe ±N is requested; load full PNGs on a **background thread pool**; + on arrival, set `raw_pixels` + `texture_dirty`. Render uses full `raw_pixels` if resident, else the + upscaled proxy. Reused by the exporter (already frame-by-frame). + +### 3b. Residency window + eviction **[DONE]** +- Added `#[serde(skip)] dirty: bool` (edited-since-persist; distinct from `texture_dirty`). Set on + stroke/fill/paint-bucket/floating-lift commits + undo/redo; cleared on save (which re-arms the LRU). +- Implemented as a fault-in-recency **LRU** (`RASTER_RESIDENT_MAX = 12`), not a strict ±N window: + evict the oldest **clean** frame (drop `raw_pixels`, re-arm `needs_fault_in`); the shown frame is + always most-recent so it's protected; **dirty frames never evicted**. Save preserves evicted frames' + rows via `media_exists` (no data loss) and walks all layers to match load. + *(Refinement deferred: count budget → byte budget for 4K resolution-robustness.)* + +### 3c. Bound the GPU cache **[DONE for raster_layer_cache]** +`raster_layer_cache` (`gpu_brush.rs`, `HashMap`, Rgba16Float ping-pong +≈ `w·h·16`/entry, was **unbounded**) → recency LRU (`RASTER_LAYER_CACHE_MAX = 12`) in +`ensure_layer_texture`: bump-to-most-recent + evict oldest; shown frames protected. F3 overlay +now shows tracked VRAM (raster cache MB + count). *(Refinements: count→byte budget; raise/headroom +if >12 raster layers are visible at once. Export `raster_cache` lives one export — fine. Vello +`ImageCache` is image *assets* → Phase 4.)* + +### 3d. Undo memory **[DONE]** +`RasterStrokeAction`/`RasterFillAction` stored `buffer_before`+`buffer_after` full frames. +Now store a `RasterDiff` (`actions/raster_diff.rs`) — changed bbox before/after only, computed in +`new()`, full buffers dropped. Undo/redo apply onto the keyframe's resident pixels; the editor +faults the target frame in first (`Action::raster_resident_hint` + `peek_undo/redo_raster_hint`), +correct because a clean evicted frame's container bytes == its logical state. Non-resident base ⇒ +skip (no corruption). Unit-tested round-trip. *(Refinement: compress full-canvas-fill diffs, whose +bbox is the whole frame.)* + +### 3e. Prefetch frames **[DONE for playback]** +Implemented for playback: each update during playback, page in the next `PREFETCH_AHEAD=4` +upcoming keyframes per raster layer (reusing the async worker + `raster_loads_inflight` dedup), so +full frames are resident before the playhead arrives — fixes "proxy on every frame"/flicker during +playback. *(Caveat: with many simultaneous raster layers the 12-frame resident budget may evict a +prefetched frame before it's shown — raise budget or scale prefetch if that surfaces. Scrub-direction +prefetch still TODO.)* + +Original note: *(future, after 3d — pure latency win, no correctness need)* +Fault-in is reactive (page in only on a render miss), so a never-visited frame still shows the +proxy for a beat before the full lands. **Prefetch the full pixels for frames about to be shown**: +on scrub/playback, dispatch background page-ins for the active keyframe ±N in the direction of +playhead motion (and during playback, the next K keyframes), reusing the 3a-2 async worker + +`raster_loads_inflight` dedup. Keep prefetched frames in the 3b LRU so they're still bounded; cap +concurrent prefetch loads so scrubbing fast doesn't thrash the disk. Optional: also prewarm the GPU +texture (3c cache) for the immediate next frame. Net effect: cold scrubbing/playback shows full-res +frames with no proxy flicker. Proxy stays as the instant fallback when prefetch can't keep up. + +### Build order & tests +1. Arc drive-by — COW make_mut test. 2. 3a fault-in + store + proxy — load→empty-until-faulted, +PNG round-trip, proxy-then-swap. 3. 3b window/evict/dirty — residency ≤ window while scrubbing, +dirty never evicted. 4. 3c GPU bound. 5. 3d undo diffs reproduce pre-stroke buffer exactly. + +--- + +## Phase 3.5 — Image textures in vector scenes **[DONE 2026-06-21]** *(prereq for Phase 4; fixed DCEL-broken image import)* + +**Done:** 3.5a — import/drop places an image as a borderless image-filled rectangle +(`AddShapeAction::image_rect`), centered (direct import) or at the drop point (library drag); +renderer now maps the image brush onto the fill's bounding box (was anchored at world origin → +only a corner showed); `SetImageFillAction` + an **Image** fill-type tab (None|Solid|Gradient|Image) +with an asset picker in the Info Panel. 3.5b — image bytes persist as `MediaKind::ImageAsset` rows in +the `.beam` (kept-in-place; `ImageAsset.data` is `skip_serializing` + container-backed; old base64 +projects migrate on re-save); eager-read on load. *(ImageCache still unbounded — Phase 4 adds the +usage-based LRU/lazy paging.)* + +### (original plan below) +## Phase 3.5 — Image textures in vector scenes *(prereq for testing Phase 4; fixes DCEL-broken image import)* + +**Why:** Phase 4 pages *image assets*, but there's currently no way to get an image asset into a +vector scene — so nothing to page. This also repairs image import, half-broken since the DCEL switch. + +**Current state (audited 2026-06-21):** +- *Works:* `import_image` (`main.rs`) decodes dims + creates an `ImageAsset` (raw bytes embedded in + `Document::image_assets`, serialized as **base64 in project JSON**). The renderer's image-fill paths + are **complete** — GPU/Vello (`renderer.rs:~1160`, `ImageBrush` via `ImageCache.get_or_decode`) and + CPU/tiny-skia (`renderer.rs:~1486`). `Fill::image_fill` (`vector_graph/mod.rs:110`) and + `Face::image_fill` (`dcel2/mod.rs:117`) fields exist and render when set. +- *Broken/missing (the workflow):* + 1. **Drop image → canvas is stubbed:** `stage.rs:~11782` and `main.rs:~4924` both just print + "Image drag to stage not yet supported with DCEL backend". Nothing is added to the scene. + 2. **No way to assign an image fill:** no `SetImageFillAction` (only `SetFillPaintAction` for + color/gradient); no Info-Panel picker. `Fill`/`Face.image_fill` are never populated. + 3. **DCEL faces never get `image_fill`** (`dcel2/import.rs:275` always `None`; topology copies from + parent which is also `None`). + 4. **Not in the container:** `MediaKind::ImageAsset` exists but is **dead** — image bytes live only + as base64 in project JSON. Not chunked, not pageable (so Phase 4 can't page them). + +**Tasks:** +- **3.5a — Place + assign.** Replace the two drop stubs: dropping an image onto a vector layer creates + a rectangle face sized to the image at the drop point with `image_fill = asset_id`. Add + `SetImageFillAction` (set/clear an image fill on the selected face/shape; mirrors `SetFillPaintAction`) + + an Info-Panel image-asset picker for the selected shape's fill. Populate `Face.image_fill` in DCEL + (and keep it through topology ops — already copied from parent). +- **3.5b — Persist in the container.** Write image assets as `MediaKind::ImageAsset` rows in the `.beam` + SQLite (like raster/audio: write on save kept-in-place on re-save; read on load), keyed by asset id; + drop the base64-in-JSON embedding (or keep a tiny ref). This is the storage Phase 4 pages from. +- **3.5c — Lazy decode hook.** Image bytes load from the container into `ImageCache` on first render + (decode → `ImageBrush`/`Pixmap`). Leave `ImageCache` **unbounded for now**; Phase 4 adds the + usage-based LRU/eviction (this phase just makes there *be* real, container-backed image assets to page). +- **Tests:** import→drop→render round-trip; save/reload preserves the image fill + reads bytes from the + container (not JSON); CPU and GPU render paths both show the image. + +--- + +## Phase 4 — Asset paging by usage + LRU *(vector's real cost is assets, not geometry)* + +Vector geometry is compact flat POD (tens of KB/frame, no cached tessellation/DCEL) — leave +it resident. The heavy, evictable thing is the **image assets** referenced by fills. + +**Data model.** +- `ImageAsset` (`clip.rs:250`): `path: PathBuf` + `data: Option>` (whole compressed + file bytes) + dims. Imported fully into `data` at `main.rs:3936`. +- All assets resident in `Document.image_assets: HashMap` (`document.rs:206`). +- Decoded form in `ImageCache` (`renderer.rs:25`): `HashMap>` + CPU + `Pixmap` map, keyed by asset id, **unbounded**. +- A `Fill` references an asset by `image_fill: Option` (`vector_graph/mod.rs:110`). + Same UUID may appear in many fills/keyframes/layers and recursively through clip instances. + **No asset→frame or frame→asset index exists today.** + +**Two evictable tiers:** Tier 1 = compressed bytes (`ImageAsset.data`, droppable, reload +from blob row or external `path`); Tier 2 = decoded pixels (`ImageCache` + GPU textures — +the heavy one). + +**Progress (2026-06-21):** +- **[DONE] Tier 2 — bound the decoded `ImageCache`.** 256 MB **usage-LRU**: every + `get_or_decode`/`_cpu` bumps the asset's recency; inserts past budget evict the least-recently-used + (a miss re-decodes from `asset.data`). Achieves usage-based eviction via render-access recency + (simpler than the frame→asset enumeration below; that enumeration is only needed for *prefetch*). +- **[DONE] Tier 1 — lazy compressed bytes.** `ImageCache` holds the container path (threaded + App.current_file_path → SharedPaneState → VelloRenderContext) and pages bytes on a decode miss via + `read_packed_media_readonly`; `load_beam_sqlite` no longer eager-reads → instant load, compressed + bytes don't accumulate. `asset.data` is still used when resident (fresh import / old base64 project). + *(Refinement: persistent read connection vs open-per-miss.)* +- **[DONE] Prefetch.** `assets_needed_at(document, time)` enumerates image ids in the visible vector + layers' active keyframes; during playback the stage decodes the ~0.5s-ahead set into the cache. + *(Refinements: nested clip-instance recursion; background-thread decode.)* + +**Phase 4 = DONE** (image asset paging by usage + LRU). + +### 4a. Frame→asset enumeration (incl. nested clips — see note below) +A function `assets_needed_at(time) -> HashSet`: walk each visible vector layer's active +`ShapeKeyframe`, collect `fill.image_fill` across its `VectorGraph.fills`, **recursing into +clip instances** with the outer→inner local-time mapping. This is "needed now". Scanning +upcoming keyframes (and upcoming nested-clip keyframes) gives "needed soon" for prefetch. + +### 4b. Usage bookkeeping (the multi-frame problem) +Maintain a reverse index `asset_id → usage count` (fills referencing it across the whole +document), updated incrementally as edits add/remove `image_fill`s (hook the fill-mutation +paths in `vector_graph` and the relevant actions). +- count 0 → dead, fully evictable / GC candidate. +- count > 0 → keep metadata; residency of `data`/decoded pixels driven by **proximity to + playhead**, not by count (a high-count asset far from the playhead is still evicted). + +Residency decision: `resident = needed-now ∪ needed-soon`; beyond that, an **LRU with a byte +budget** for referenced-but-distant assets (covers scrubbing back without a reload). +Eviction never touches an asset in needed-now. + +### 4c. Bound the decoded tier +Convert `ImageCache`'s two maps to LRU/byte-budgeted (`renderer.rs:25`) and bound the GPU +image-texture cache the same way, keyed to the residency window. + +### Nested-clip prefetch (important) +A clip instance placed on an outer frame has its **own internal timeline of keyframes**, +each of which can reference its own image assets. Prefetch must therefore: +- Recurse through clip instances when computing both needed-now and needed-soon. +- Map outer playhead time → each nested clip's local time, and look ahead along the + **nested** timeline (not just the outer one) so assets used by an upcoming *inner* + keyframe are loaded before the nested clip reaches it. +- Deduplicate across the whole recursion (an asset shared by outer and inner frames counts + once); the usage index handles refcounting. + +--- + +## Cross-cutting: a shared residency abstraction + +A generic **`PagedStore`** with three consumers — always-resident metadata, +disk backing, residency = window/needed-set around playhead + LRU byte budget: + +| Consumer | Metadata kept | Paged payload | Backing | "Needed now" key | +|---|---|---|---|---| +| Raster keyframes (Ph 3) | id, dims, time | `raw_pixels` + GPU texture | SQLite blob row (`UPDATE` on write-back) | active keyframe per layer | +| Image assets (Ph 4) | id, dims, storage | `data` bytes + decoded pixels/texture | SQLite blob row or external path | fills' `image_fill` set at time (recursive) | +| Video frames (Ph 2a) | — | RGBA frame | source via ffmpeg seek | requested timestamps | + +Audio stays separate (real-time ring buffer, different constraints). The frame→asset +enumeration + usage index is unique to Phase 4. + +--- + +## Sequencing +1. **Phase 1a** — done; independent of the container, works with the current ZIP loader. +2. **Phase 2** — small, isolated, independently shippable; container-independent. +3. **Phase 0 (container)** — `.beam` ZIP → SQLite + `BlobReader` + large-media policy + + legacy-ZIP migration. Prerequisite for 1b/1c/3/4. +4. **Phase 1b** — streaming pool loader + bulk read-ahead activation (on the SQLite store). +5. **Phase 1c** — depends on 1b's pool path. +6. **Phase 3** — the substantial build; implement `PagedStore` over blob rows. +7. **Phase 4** — thin layer on the same abstraction + the frame→asset/usage index. + +Phase 1a and Phase 2 can ship now; everything else waits on Phase 0 (the container). + +--- + +## Status +- [~] Phase 1a — activate compressed-audio disk reader ← **in progress** + - [x] Wire `ActivateFile` + assign `clip.read_ahead` on `AddAudioClip` for compressed + pool files (`engine.rs:909`). Per-clip reader keyed by `clip_id`; matches the + existing `DeactivateFile` convention in `RemoveAudioClip`. Compiles clean. + - [ ] Stop `decode_progressive` (`io/audio_file.rs:344`) from accumulating/streaming the + full PCM; emit only the downsampled waveform overview. (Crosses into the UI + waveform pipeline — `AudioDecodeProgress` consumer — so handled as its own step.) + - [ ] Runtime verification: confirm a compressed clip actually plays from the ring + buffer (was effectively silent before, since `read_ahead` was always `None`). +- [~] **Phase 0 — container migration `.beam` ZIP → SQLite** ← **in progress** + - [x] SQLite schema (`media`, `media_chunk`, `project_json`, `meta`) + `rusqlite` dep + (bundled) — `lightningbeam-core/src/beam_archive.rs` + - [x] `BlobReader` (`Read + Seek` over `media_chunk`, owns its own read-only connection, + opens a blob handle per read with rowids resolved once) — for `CompressedReader` / + video decoder in 1b. 5 integration tests pass (`tests/beam_archive.rs`): json + round-trip, packed full read, streaming reads + seeks across chunk boundaries, + referenced-path, overwrite-replaces-chunks. + - [x] Packed (chunked) + referenced media write/read API; `is_sqlite()` format detection; + `MediaKind`/`MediaStorage`/`MediaMeta`/`MediaInfo`. + - [x] `BeamArchive::transaction()` / `BeamTxn` — in-place transactional save (only + changed rows written; unchanged large media never rewritten); orphan cleanup via + `retain_media`. 7 archive tests pass (added txn-grouping + rollback). Per user: save + must NOT copy+rename for existing SQLite files. + - [x] Wire `save_beam` to `BeamArchive` — in-place txn for existing SQLite, temp+rename + only for new/migrated files. Audio → packed (or referenced ≥2GB) `media` rows; + raster → PNG `media` rows keyed by keyframe id. FLAC→WAV→base64 save round-trip + deleted (now packs original bytes with their codec). + - [x] Wire `load_beam` — format dispatch: SQLite (`load_beam_sqlite`) vs legacy ZIP + (`load_beam_zip_legacy`, kept verbatim). SQLite load reconstitutes packed audio into + `embedded_data` so the existing pool loader is unchanged (streaming = Phase 1b). + - [x] Legacy ZIP `.beam` → SQLite migration: `is_sqlite()` routes load; saving a + ZIP-loaded project writes SQLite (migrates on save). Editor compiles end-to-end. + - [x] Large-media policy: packed (chunked) vs referenced — `LargeMediaMode {Ask,Pack, + Reference}`; save honors it for files ≥`LARGE_MEDIA_THRESHOLD`. Packing streams from + disk via `put_media_packed_from_path` (chunk-by-chunk, never loads the whole file). + `Ask` behaves as `Reference` at save time. + - [x] `large_media_default` user preference: persisted in `AppConfig`, editable in + Preferences → Advanced (incl. resetting to `Ask` to re-trigger the prompt). + - [x] First-import-over-threshold prompt: `note_possible_large_media` (hooked into + import_audio/video/image) queues a one-time modal; choice persists to config. + Threshold shown in the modal is derived from the constant. + - [ ] Runtime verification: save a real project, reopen it, confirm audio + raster survive + round-trip; confirm an old ZIP `.beam` still opens and migrates on save. + - [ ] (Optimization, later) FLAC-compress packed PCM/WAV audio; raster disk-dirty flag to + skip unchanged frames on in-place save (Phase 3). + +> Note: the crate's internal `#[cfg(test)]` modules (`clip.rs`, `effect_layer.rs`) have +> pre-existing compile breakage (old `Beats`/`TempoMap` API) unrelated to this work; it +> blocks `cargo test --lib`, so `beam_archive` tests live in `tests/` (integration) which +> build the lib in normal mode. Worth fixing separately. +- [x] Phase 1b — stream on project load (PACKED audio path complete & user-verified: streams on load, + waveform generates + persists, sample-accurate seeking). Referenced-path streaming + MP3 seek index + + proper video-audio reload remain as noted follow-ups. + - **Decision (user):** cross-crate packed streaming via an **inversion-of-control factory** — + daw-backend defines the interface, core implements it over `BlobReader`. Keeps the audio + engine container-agnostic. (Alternatives rejected: daw-backend owning rusqlite = layering + violation; referenced-only-first = leaves packed <2GB in RAM.) + - **Current load reality (why this is needed):** *nothing* streams on load today — every entry + is fully decoded to a PCM `Vec`. Packed audio is base64-reconstituted into `embedded_data` + (`load_beam_sqlite`) → written to a temp file → `load_file_into_pool` full-decodes; referenced + audio also full-decodes via `load_file_into_pool`; and the Phase 1a/1c disk-reader activation + never fires for loaded clips (they bypass `AddAudioClip`). + - [x] **B1/B2 foundation (DONE, headless-tested):** in `disk_reader.rs` — `trait MediaByteSource: + Read+Seek+Send+Sync { byte_len }` + `trait AudioBlobSourceFactory: Send+Sync { open(media_id) + -> Box }`; `SymphoniaByteSource` adapter (impl `MediaSource`, + is_seekable/byte_len); `CompressedReader::open_source(src, ext)` sharing probe via a + refactored `from_mss`; `enum StreamOpen { Path, Source{src,ext} }`; `StreamSource::open` and + `DiskReaderCommand::ActivateFile` now take `StreamOpen` (engine site wraps `Path`); re-exported + `AudioBlobSourceFactory`/`MediaByteSource` at `daw_backend::audio`. Test + `tests/compressed_source_stream.rs` decodes an in-memory WAV through a `Cursor`-backed + `MediaByteSource` (proves probe+decode+seek over a byte stream). daw-backend compiles clean. + - [x] **B3 (engine, DONE):** `Engine.blob_source_factory: Option>` + + `EngineController::set_blob_source_factory` (via `Query::SetBlobSourceFactory`, ordered before + `SetProject` on the same queue). `AudioFile.packed_media_id: Option` (Some ⇒ open via + factory using `original_format` as the ext hint; None ⇒ `StreamOpen::Path`). Activation factored + into `Engine::activate_streaming_for(reader_id, pool_index)`, used by `AddAudioClip` and bulk. + - [x] **C (core factory, DONE):** `file_io::blob_source_factory(beam_path)` → `BeamBlobFactory` + implementing `AudioBlobSourceFactory` over `BeamArchive::open_blob_reader`. `BlobReader` holds a + `!Sync` rusqlite `Connection`, so it's wrapped in `SyncBlobReader` (a `Mutex` used via `get_mut` + on the hot path — no runtime locking) to satisfy Symphonia's `MediaSource: Send + Sync`. Installed + by the editor between `load_audio_pool` and `set_project`. + - [x] **D (load-path, DONE — packed audio):** `load_beam_sqlite` now streams packed audio whose codec + is recognized (`is_streamable_audio_codec`) — leaves `embedded_data` empty so the pool builds a + Compressed placeholder with `packed_media_id`; no base64, no temp file, no decode. `serialize` + round-trips packed entries by media id (so in-place re-save keeps the row). Non-audio codecs + (video-container audio tracks) keep the legacy reconstitution path → **no regression**. + - [x] **E (bulk activation, DONE):** `SetProject` calls `Engine::activate_all_streaming_clips` — + walks every loaded audio clip and `activate_streaming_for` (create_buffer + `ActivateFile` + set + `read_ahead`), the loaded-clip equivalent of the Phase 1a wiring. + - [x] **Waveform-on-load for streamed audio (DONE):** streaming broke the old waveform path (it came + from the full in-RAM decode, which no longer happens). Added + `disk_reader::build_waveform_pyramid_from_source(Box, ext, B)` (load-time + counterpart of the path-based builder). On load, the editor background-generates a pyramid for any + streamed entry lacking a persisted one (opens the packed blob via a local factory), sending the + floor through the same `waveform_result` channel `update()` drains; the next save persists it. + Verified in-app: packed MP3 **streams + plays** (`Activated reader=0, kind=CompressedAudio`); the + overview now fills in shortly after load. + - **Headless tests pass** (compressed_source_stream, video_audio_stream, waveform_pyramid); all three + crates compile clean. **Needs in-app verification:** the waveform appears after load (background gen), + then instantly on subsequent loads once saved; RAM stays flat on a big project. + - [x] **Seek alignment fix (DONE):** streamed compressed audio was ~1.2s off *after seeking* + (fine from the start). `CompressedReader::seek` used `SeekMode::Coarse`, which for MP3 + byte-estimates the position and seeds the timestamp from that estimate — wrong for VBR / files + whose header padding the estimate ignores, so `actual_ts` (and thus the buffer's frame labels) + landed ~1.2s early. Switched to `SeekMode::Accurate`: Symphonia counts frame *headers* (no + decode) from a true anchor (current pos, or rewind-to-0 for backward seeks) → exact `actual_ts`; + the existing sub-frame `pending_discard` finishes the job. FLAC/OGG seek cheaply (seek tables); + a long MP3 backward seek walks headers from 0 (I/O, not decode). Tests still green. + - [ ] **Deferred (follow-up):** per-file **seek index** for elementary streams (MP3) — a one-time + header scan (ts↔byte map) to make far seeks O(1) instead of an Accurate header-walk from the + anchor. Matters for multi-hour MP3s; song-length files are fine as-is. + - [x] **Proper video-audio reload (DONE):** a video's audio is now stored as a **path reference** to + the video (never packed/embedded as audio media) and **re-probed via FFmpeg** on load into a + streaming `VideoAudio` entry — `AudioPoolEntry.is_video_audio` flag drives both `serialize` + (reference, not pack), `save_beam` (`reference_it |= is_video_audio`), and `load_from_serialized` + (`VideoAudioReader::open` → `from_video_audio`). Fixes 5.1 audio losing its channels on reload + (the old Symphonia reconstitution collapsed it); also no more decode-whole-video-to-RAM / temp + files on load. Old saves (video mis-packed as audio) self-heal on the next save. + - [ ] **Deferred (follow-up):** stream *referenced* (external-path) **audio** on load too — replace + `load_file_into_pool`'s full decode with the `do_import_audio` branching (PCM → mmap, compressed + → `from_compressed` placeholder). Higher risk (touches the working referenced path); packed + covers the common <2GB case first. + - [ ] **Deferred (follow-up): packed video streaming.** Let small videos be packed into the `.beam` + (a `MediaKind::Video` blob, `VideoClip` referencing it by id) and stream **both frames and audio** + from the DB blob via FFmpeg. ffmpeg-next has no custom-I/O wrapper, so this needs an + `AVIOContext`-over-`BlobReader` shim via raw FFI. **Decision (user):** that FFI wrapper lives in + its **own crate, version-pinned to the ffmpeg version**, isolating the unsafe + the ABI coupling. +- [~] Phase 1c — video embedded-audio track ← **stopgap shipped; proper design next** + - [x] Stopgap: `extract_audio_from_video_to_wav` streams to a temp WAV → `import_audio_sync` + (mmap). Fixed the ~2.8GB-`Vec` OOM. But writes the whole WAV to `/tmp` (fills + small temp partitions) and the temp path doesn't survive reload. + - [~] **Proper design** (see "Phase 1c" body): stream the video's audio on demand via a new + ffmpeg `VideoAudioReader` in the disk reader — no extraction, no `/tmp`, no RAM; path + reference survives save/load. + - [x] **Step 1 (DONE):** `VideoAudioReader` (ffmpeg) + `StreamSource` enum + `SourceKind` + in `disk_reader.rs`. Sample-accurate seek (coarse seek + decode-discard to exact + frame via PTS). 2 integration tests pass (`daw-backend/tests/video_audio_stream.rs`): + in-order decode + sample-exact seek at several targets. (Found: mono frames have an + empty channel layout → must `set_channel_layout` before resampling, else swr returns + AVERROR_INPUT_CHANGED.) Lib compiles clean; `StreamSource` `#[allow(dead_code)]` + until wired. `VideoAudioReader` made `pub` for the integration test. + - [x] **Step 2 (DONE):** `AudioStorage::VideoAudio { decoded_for_waveform, decoded_frames, + total_frames }` + `AudioFile::from_video_audio` (path = the video file). `data()` + empty / `read_samples()` 0 (streamed). `Query::AddVideoAudioSync` + + `do_add_video_audio` (probes via `VideoAudioReader::open`, no decode) + + `EngineController::add_video_audio_sync`. `GetPoolAudioSamples` surfaces VideoAudio's + waveform overview too. daw-backend compiles clean; probe `total_frames` test passes. + - [x] **Step 3 (DONE):** reader thread now holds `StreamSource` (opens via + `StreamSource::open(path, kind)`, dispatches `sample_rate()/channels()/seek/decode_next`); + `ActivateFile` carries `kind: SourceKind`; `#[allow(dead_code)]` removed. `AddAudioClip` + activation maps `Compressed`→`CompressedAudio`, `VideoAudio`→`VideoAudio`, creates the + read-ahead buffer + `ActivateFile{kind}` + sets `clip.read_ahead`. Compressed path is + behaviorally identical (StreamSource::Compressed wraps the same CompressedReader). + daw-backend + editor compile clean; VideoAudioReader tests still pass. + ⚠️ Not runtime-verified — needs in-app check that compressed audio still plays (no + regression) and that an activated VideoAudio clip produces sound. + - [x] **Step 4 (DONE):** `import_video` now calls `add_video_audio_sync(video_path)` → + pool index, fetches channels/sample_rate via `get_pool_file_info`, makes the + `AudioClip` with the video's duration. **No WAV / /tmp / RAM.** Removed the stopgap + (`extract_audio_from_video_to_wav` + WAV helpers + `ExtractedAudioInfo`), dead + `add_audio_file_sync` (+ `Query::AddAudioFileSync` / `QueryResponse::AudioFileAddedSync` + / handler), and the now-unreachable `AudioExtractionResult::NoAudio`. Kept + `import_audio_sync` (still used by normal audio import). daw-backend + editor clean. + **→ Feature is live end-to-end; ready for in-app testing.** + - [x] **Step 5 (DONE):** `CompressedReader` now seeks sample-accurately too — coarse + symphonia seek + decode-discard (`pending_discard` set from `seeked.actual_ts` in + `seek`, applied in `decode_next`, which continues rather than reporting EOF when a + whole packet is discarded). So compressed clips no longer drift vs video audio after + a seek. Test `compressed_reader_seek_is_sample_accurate` passes (the WAV coarse seek + lands pre-target, exercising the discard). `CompressedReader` made `pub` for the test. + - [~] Step 6: **bounded waveform overview** — replaces today's full-resolution + `raw_audio_cache`/GPU waveform (which doesn't scale: it stores every sample at mip 0, + so a long file is multi-GB on GPU + RAM — the same memory issue, and the Phase 1a + `decode_progressive` leftover). Design below. Slices: (1a) streaming pyramid builder + + (1b) persistence + (1c) min/max GPU upload, then (2) LRU tile cache + re-decode floor. + - [x] **Slice 1a (DONE):** `daw-backend/src/audio/waveform_pyramid.rs` — + `WaveformPyramidBuilder` streams interleaved samples, accumulates the floor, and + reduces `BRANCH(4):1` at `finish` into a root-first pyramid (convention B: + `levels[0]`=root envelope, `levels.last()`=floor, `.root()`/`.floor()` accessors). + Ragged last buckets reduce over available children (no value padding). Bounded + (~22 MB/2 h @ B=256). 7 integration tests pass (`tests/waveform_pyramid.rs`): + bucket min/max, partial flush, multi-level envelope == global min/max, root-first + ordering, stereo channels, size bound, chunk-agnostic. + - [~] **Slice 1b (data layer DONE; orchestration folded into 1c):** + - [x] Generation bridge `disk_reader::build_waveform_pyramid(path, kind, B)` — streams + a decode (`StreamSource` over symphonia/ffmpeg) into the builder; bounded + memory (one chunk + the pyramid). Test: envelope matches the signal through + both backends. + - [x] Serialization `WaveformPyramid::to_bytes`/`from_bytes` (LBWF blob; f32 texels — + f16 a later size optimization). Round-trip test + rejects truncated/garbage. + - [x] `MediaKind::Waveform` in the SQLite container (keyed by the audio item's id). + - [ ] Orchestration (with 1c). + - [~] **Slice 1c (in-memory floor overview DONE; persistence next):** + - [x] `waveform_gpu`: `PendingUpload.minmax` flag + `pack_texel` helper; `upload_audio` + threads `minmax` (frame_stride 4, packs `(Lmin,Lmax,Rmin,Rmax)` directly). + The texture is already Rgba16Float and the GPU mipgen builds zoom-out levels, so + only the texel-packing differs. Render the floor at **effective rate `sr/B`** (so + time→texel maps B samples/texel) and `total_frames = floor_texel_count`. + - [x] `AppConfig.waveform_floor_samples_per_texel` (default 256, user-configurable). + - [x] App: `waveform_minmax_pools: HashMap` (pool → `B`, carries the floor rate + with full float precision) + a `(pool, packed_floor, sr, channels, B)` results channel; + drained in `update()` → `raw_audio_cache.insert(floor)` + flag pool + `waveform_gpu_dirty`. + - [x] Generation: on video-audio import Success, the same bg thread streams + `disk_reader::build_waveform_pyramid(path, VideoAudio, B)` once and sends the packed + `floor()`. (Video-audio has no in-RAM samples, so this is what makes its waveform appear.) + - [x] Threaded `waveform_minmax_pools` through the pane-context (`panes/mod.rs` + + main.rs construction) → `render_layers` → **both** render sites (collapsed-group + ~timeline.rs:3048 AND expanded-track ~3613): compute `total_frames = len/4`, + `eff_sr = sr/B`, set `minmax`. Compiles clean (editor `cargo check` = 0 errors). + - [x] Shader fix: `waveform.wgsl` now reads the **nearest integer LOD via `textureLoad`** + instead of sampling a fractional mip. Trilinear blends two levels whose row-major + linearizations differ → horizontal shift that flips each 0.5 of `mip_f` (= each 2x + zoom step), the "every other zoom level is offset" artifact. **User-confirmed fixed:** + features hold position at every zoom and line up with playback. + See memory `waveform-shader-fractional-mip-offset`. + - [x] **Persistence (done):** the full pyramid is serialized (`to_bytes`) on generation and + kept in `App.waveform_pyramid_blobs`. `save_beam` writes it as a `MediaKind::Waveform` + row keyed by a **deterministic id derived from the pool index** (`file_io::waveform_media_id`, + "LBWF" sentinel in the high 32 bits) — independent of how the audio bytes are stored, so + it works for packed/referenced/video-audio alike, and an in-place re-save reuses the row. + Carried in/out via a transient `#[serde(skip)] AudioPoolEntry.waveform_blob` and a + `waveform_blobs` field on `FileCommand::Save`. `load_beam_sqlite` reads the row back; + the editor restores `raw_audio_cache`/`waveform_minmax_pools`/`waveform_pyramid_blobs` + + flags `waveform_gpu_dirty` after the backend loads the pool (using each entry's + `sample_rate` for `eff_sr`, the stored `B` for the rate). No re-decode on load. + `register_loaded_videos` only loads frames (not audio), so there is no redundant + regeneration to suppress. Compiles clean across all three crates. + +### Waveform LOD pyramid design (step 6) +A min/max LOD pyramid (tree of zoom-level textures): fully zoomed out → envelope; fully zoomed +in → per-sample; seamless between. + +- **One streaming decode pass** builds the whole pyramid down to a configurable **floor** + `B` samples/texel (default 256), via a hierarchical reduction (each sample updates a running + per-level min/max accumulator; a filled bucket emits a texel and folds into its parent — + `branch` 4:1). Bounded memory: holds only the pyramid (~`N/B·4/3` texels ≈ **~14 MB / 2 h + stereo @ B=256**), never the full samples. Full-res (B=1 ≈ 2.7 GB) is the only level NOT + stored. +- **Persist the pyramid** in the `.beam` SQLite container (a `waveform` media kind; session + temp before first save). `B` is stored with it (preference is just the default for new gen). + Persistence is load-bearing: it makes mid-zoom a cheap **disk read**, not a re-decode. +- **Runtime = LRU tile cache** (GPU textures) loaded from the persisted pyramid on demand. + Eviction is **ancestor-closed**: only evict an LRU node with no resident children ("a node is + cleared only after its children") — so rendering can always walk up to a resident ancestor; + detail sharpens in, never blanks. Root is tiny/hot → effectively pinned for free. +- **Re-decode only below the floor** (texel < `B` samples): by then the visible window spans a + tiny time range, so decoding it (via the sample-accurate seekable readers from steps 1–5 — + the payoff) for true per-sample detail is cheap. This removes the large-span re-decode gap: + above the floor it's a disk read; below it the span is already small. +- **Why a deep floor (not a coarse cutoff):** a coarse-only pinned set would force the first + on-demand level to re-reduce a huge time span per tile. Persisting deep makes every level a + disk read; `B` is a size-vs-crossover knob (smaller B = bigger pyramid, cheaper re-decode). +- `waveform_gpu` needs a **min/max texel upload** (`Lmin,Lmax,Rmin,Rmax` per texel) instead of + min=max-per-sample; the existing compute mipgen still builds the mip chain *within* a tile. + +**Decisions (locked):** branch 4:1; floor `B≈256` samples/texel, **user-configurable** +(`AppConfig.waveform_floor_samples_per_texel`, stored per-pyramid); 8192-wide tiles; LRU ~4 +viewports of fine tiles; persist pyramid in `.beam`. +- [x] Video decoder concurrency (movie-length lag/freeze): keyframe-index scan now runs + holding no VideoManager/decoder lock (brief locks only bracket it) → no more multi-second + UI freeze on import/load; thumbnail generation uses a **dedicated** decoder and samples + at keyframes (≈1 frame each vs whole-GOP) → no playback contention. Removed dead + `VideoManager::build_keyframe_index`, `build_and_set_keyframe_index`, `downsample_rgba*`. +- [x] Phase 2a — bound video frame cache. `VideoManager.frame_cache` (was an unbounded + `HashMap<(Uuid,i64), Arc>` that grew per distinct frame during playback) is now an + `LruCache` evicted by a **byte budget** (`FRAME_CACHE_BYTE_BUDGET` = 256 MB) rather than a frame + count — robust across resolutions (a 4K frame is ~33 MB vs ~2 MB at 800×600). Byte total tracked + on insert/evict/remove; `unload_video` pops per-clip keys (LruCache has no `retain`). Decoder-level + cache was already LRU. Editor compiles clean. *(Not yet runtime-verified.)* +- [x] Phase 2b — stream export mux. `export/mod.rs::mux_video_and_audio` no longer collects every + packet into two `Vec`s before interleaving; it stream-merges the two inputs by PTS with one pending + packet per stream (O(1) memory vs O(duration)). Same tie-break (`v_us <= a_us`) and drain-on-EOF + behavior; output is byte-identical. Editor compiles clean. *(Not yet runtime-verified — needs an + in-app export to confirm A/V sync.)* +- [x] Phase 3a — lazy + async raster fault-in (`RasterStore` + background thread + image proxy) +- [x] Phase 3b — raster residency LRU + eviction (dirty-flag data-loss safety) +- [x] Phase 3c — bound raster GPU texture cache (recency LRU + F3 VRAM readout) +- [x] Phase 3d — raster undo dirty-rect diffs (+ fault-in-before-undo) +- [x] Phase 3.5 — image textures in vector scenes (fixed DCEL-broken image import; image-fill tab + picker; container-persisted) +- [x] Phase 4 — image asset paging: Tier 2 decoded-cache byte-LRU, Tier 1 lazy container bytes, playback prefetch +- [x] Phase 5 — fixed the broken `#[cfg(test)]` unit tests; **`cargo test --lib` green again** + (daw-backend 17 passed, lightningbeam-core 264 passed). Wrapped stale raw-`f64` time literals + in `Beats(...)` / passed `&TempoMap` to changed signatures (automation.rs, clip.rs, + effect_layer.rs); fixed stale test setup (register a vector clip so `get_clip_duration` resolves) + and a stale default expectation (shape `fill_color` defaults `None`). Surfaced + fixed one **real + undo bug**: `DeleteFolderAction(MoveToParent)` reparented child subfolders but never restored them + on rollback (orphaned them) — now tracked and restored. Production code otherwise untouched. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..4e61b42 --- /dev/null +++ b/TODO.md @@ -0,0 +1,305 @@ +# Lightningbeam TODO + +> ⚠️ **Stale entries:** Lightningbeam was rewritten from JavaScript to Rust. Any entry below +> that cites `src/*.js` / `main.js` / `animation.js` predates that migration — the *issue* may +> or may not still exist in the Rust codebase, but the file/line references are obsolete. +> **Re-verify against the current Rust code before acting** (this covers the "Animation System +> Refactoring" section and the JS-referencing "Known Issues" entries — node editor, default +> interpolation, etc.). Items with no `.js` references are current. + +## Animation System Refactoring *(STALE — JS-era migration notes; superseded by the Rust DCEL/keyframe system)* + +### Completed +- ✅ Implement AnimationData curve-based system (Keyframe, AnimationCurve, AnimationData classes) +- ✅ Add GraphicsObject.currentTime property +- ✅ Migrate shape rendering to use AnimationData curves (exists, zOrder) +- ✅ Binary search optimization for keyframe lookups + +### In Progress +- Migrating from Frame-based to AnimationData curve-based system throughout codebase + +### Pending Features + +#### Animation Curve Enhancements +- [ ] Implement extrapolation modes (separate for start vs end): + - "hold" (default) - hold value at first/last keyframe + - "extend" - linearly extend the curve beyond keyframes + - "repeat" - repeat the animation + - "decay" - exponential decay to a target value +- [ ] Add position, scale, rotation animation curves for shapes +- [ ] Add shape morphing/tweening between keyframes + +#### Keyframing Behavior +- [ ] Add user preference for keyframing behavior when editing objects: + - Auto-keyframe (current default): create/update keyframe at current time + - Edit previous (Flash-style): update most recent keyframe before current time + - Ephemeral (Blender-style): changes don't persist without manual keyframe + - Optional: Add modifier key (e.g. Shift) to toggle between modes + +#### Shape Ordering +- [ ] Add "Bring Forward" menu option (swap zOrder with shape in front) +- [ ] Add "Send Backward" menu option (swap zOrder with shape behind) +- [ ] Add "Bring to Front" menu option (set zOrder to max + 1) +- [ ] Add "Send to Back" menu option (set zOrder to min - 1) + +#### Code Cleanup +- [ ] Remove all remaining references to Frame-based system +- [ ] Remove legacy Frame class once migration is complete +- [ ] Clean up GraphicsObject.shapes[] array (shapes should only live in Layers) + +## Known Issues / Platform Limitations + +### Animation: Tweens are broken (Rust codebase) — LOW PRIORITY +- **Issue**: Animation tweening between keyframes (shape/vector interpolation, and the + `tween_after` behavior on keyframes) does not work correctly in the current Rust app. + Needs investigation + fix. Not urgent — revisit later. +- (Older JS-codebase animation entries below reference `src/*.js` and are stale.) + +### Audio: Oscillator Timbre Drift (Phase Accumulation Error) +- **Issue**: Oscillators exhibit timbre changes over time due to floating-point phase accumulation errors +- **Affected Files**: + - `daw-backend/src/effects/synth.rs:117-120` (SimpleSynth) + - `daw-backend/src/audio/node_graph/nodes/oscillator.rs:167-170` (OscillatorNode) +- **Root Cause**: Current phase wrapping uses conditional subtraction (`if phase >= 1.0 { phase -= 1.0 }`), which accumulates f32 rounding errors over time, especially for long-playing notes +- **Current Code**: + ```rust + self.phase += frequency / sample_rate; + if self.phase >= 1.0 { + self.phase -= 1.0; + } + ``` +- **Recommended Fix**: Replace with `.fract()` for numerically stable wraparound: + ```rust + self.phase += frequency / sample_rate; + self.phase = self.phase.fract(); + ``` +- **Impact**: Medium - affects audio quality for sustained notes, becomes noticeable after several seconds +- **Priority**: Medium - should be addressed before production use + +### UI: Node Connections Render Behind VoiceAllocator Child Nodes +- **Issue**: Connection lines (SVG paths) inside expanded VoiceAllocator nodes render behind child nodes due to z-index stacking +- **Affected File**: `src/styles.css:1128` +- **Root Cause**: Child nodes have `z-index: 10` while connection SVG paths have default/lower z-index +- **Current Code**: + ```css + .drawflow .drawflow-node.child-node { + opacity: 0.9; + border: 1px solid #5a5aaa !important; + box-shadow: 0 2px 8px rgba(90, 90, 170, 0.3); + z-index: 10; + } + ``` +- **Recommended Fix**: Either: + 1. Remove `z-index: 10` from `.child-node` (simplest), or + 2. Add higher z-index to connection SVG paths, or + 3. Use CSS `isolation: isolate` on the VoiceAllocator contents area to create a new stacking context +- **Impact**: Low - visual issue only, connections still function but appear to go "behind" nodes +- **Priority**: Low - cosmetic issue that doesn't affect functionality + +### UI: VoiceAllocator Child Nodes Don't Move with Parent +- **Issue**: When a VoiceAllocator node is moved, its child nodes remain in their original positions instead of moving with the parent +- **Affected File**: `src/main.js:6202-6207` +- **Root Cause**: The `nodeMoved` event handler only handles the case where a child node is moved (resizes parent), but doesn't handle when the VoiceAllocator itself is moved +- **Current Code**: + ```javascript + editor.on("nodeMoved", (nodeId) => { + const node = editor.getNodeFromId(nodeId); + if (node && node.data.parentNodeId) { + resizeVoiceAllocatorToFit(node.data.parentNodeId); + } + }); + ``` +- **Recommended Fix**: Add logic to detect when a VoiceAllocator is moved and update all child node positions: + ```javascript + editor.on("nodeMoved", (nodeId) => { + const node = editor.getNodeFromId(nodeId); + + // Case 1: A child node was moved - resize parent + if (node && node.data.parentNodeId) { + resizeVoiceAllocatorToFit(node.data.parentNodeId); + } + + // Case 2: A VoiceAllocator was moved - move all children + if (node && node.data.nodeType === 'VoiceAllocator') { + // Calculate delta from previous position (need to track) + // Update all child node positions by the delta + // Call editor.updateConnectionNodes() for parent and all children + } + }); + ``` +- **Impact**: High - child nodes become disconnected from parent visually +- **Priority**: High - breaks expected behavior of grouped nodes + +### UI: VoiceAllocator Expansion Doesn't Update Connection Positions +- **Issue**: When expanding/collapsing a VoiceAllocator, connection endpoints don't update to match the new port positions +- **Affected File**: `src/main.js:6496-6555` (handleNodeDoubleClick function) +- **Root Cause**: The expand/collapse logic shows/hides child nodes and resizes the container, but never calls `editor.updateConnectionNodes()` to refresh connection positions +- **Current Code**: In `handleNodeDoubleClick()`, after expanding or collapsing: + ```javascript + // Expand + expandedNodes.add(nodeId); + nodeElement.classList.add('expanded'); + nodeElement.style.width = '600px'; + nodeElement.style.height = '400px'; + // ... shows child nodes ... + // Missing: editor.updateConnectionNodes(`node-${nodeId}`) + ``` +- **Recommended Fix**: Call `editor.updateConnectionNodes()` after resizing: + ```javascript + // After expanding + expandedNodes.add(nodeId); + nodeElement.classList.add('expanded'); + // ... resize and show children ... + + // Update connection positions for VoiceAllocator and all children + editor.updateConnectionNodes(`node-${nodeId}`); + for (const [childId, parentId] of nodeParents.entries()) { + if (parentId === nodeId) { + editor.updateConnectionNodes(`node-${childId}`); + } + } + ``` +- **Impact**: Medium - connections appear in wrong positions until manually moved +- **Priority**: Medium - visual issue that affects usability + +### UI: Node Editor Allows Editing Without MIDI Layer Selected +- **Issue**: The node editor pane allows adding/editing instrument nodes even when no MIDI layer is selected, and always uses hardcoded `trackId: 0` +- **Affected File**: `src/main.js:6045-6920` (nodeEditor function) +- **Root Cause**: The node editor never checks if `context.activeObject.activeLayer` exists or is a MIDI track, and all backend commands use hardcoded `trackId: 0` +- **Current Code**: All graph commands hardcode track 0: + ```javascript + const commandArgs = parentNodeId + ? { + trackId: 0, // HARDCODED! + voiceAllocatorId: editor.getNodeFromId(parentNodeId).data.backendId, + nodeType: nodeType, + x: x, + y: y + } + : { + trackId: 0, // HARDCODED! + nodeType: nodeType, + x: x, + y: y + }; + ``` +- **Recommended Fix**: + 1. Check if activeLayer is a MIDI track before allowing edits: + ```javascript + function getSelectedMidiTrack() { + const activeLayer = context.activeObject?.activeLayer; + if (!activeLayer || activeLayer.type !== 'midi') { + return null; + } + return activeLayer; + } + ``` + 2. Show placeholder when no MIDI track selected: + ```javascript + function nodeEditor() { + const container = document.createElement("div"); + const midiTrack = getSelectedMidiTrack(); + + if (!midiTrack) { + container.innerHTML = '
Select a MIDI layer to edit instruments
'; + return container; + } + // ... rest of node editor code ... + } + ``` + 3. Use actual track ID instead of hardcoded 0: + ```javascript + const trackId = midiTrack.audioTrackId || 0; + const commandArgs = { trackId, nodeType, x, y }; + ``` + 4. Add listener to refresh node editor when layer selection changes +- **Impact**: High - allows editing wrong track's instrument graph, data corruption risk +- **Priority**: High - can cause confusion and data loss + +### Animation: Wrong Default Interpolation for Shape and Object Keyframes +- **Issue**: Shape index and object transform keyframes default to "linear" interpolation but should default to "hold" (step function), and there's no UI to change interpolation after creation +- **Affected Files**: + - `src/models/animation.js:124` (Keyframe constructor defaults to "linear") + - `src/main.js:2161` (shapeIndex keyframes default to "linear") + - `src/main.js:2198` (object position/rotation/scale keyframes default to "linear") + - `src/main.js:5910` (Timeline menu - missing tween options) +- **Root Cause**: + 1. The Keyframe constructor defaults interpolation to "linear" + 2. Shape index keyframes preserve existing interpolation or default to "linear" + 3. Object transform keyframes explicitly use "linear" + 4. No menu options exist to change interpolation mode after keyframe creation +- **Current Code**: + - Keyframe constructor (animation.js:124): + ```javascript + constructor(time, value, interpolation = "linear", uuid = undefined) { + ``` + - Shape index keyframes (main.js:2161): + ```javascript + const interpolationType = existingShapeIndexKf ? existingShapeIndexKf.interpolation : 'linear'; + const shapeIndexKeyframe = new Keyframe(currentTime, newShapeIndex, interpolationType); + ``` + - Object keyframes (main.js:2198): + ```javascript + const newKeyframe = new Keyframe( + currentTime, + currentValue, + 'linear' // Default to linear interpolation + ); + ``` +- **Expected Behavior**: + - Shape index keyframes should default to "hold" (shapes shouldn't morph between versions) + - Object transforms should default to "hold" (objects shouldn't move/rotate/scale between keyframes unless explicitly tweened) + - Timeline menu should have options to convert between interpolation modes +- **Recommended Fix**: + 1. Change shapeIndex default to "hold" (main.js:2161): + ```javascript + const interpolationType = existingShapeIndexKf ? existingShapeIndexKf.interpolation : 'hold'; + ``` + 2. Change object keyframe default to "hold" (main.js:2198): + ```javascript + const newKeyframe = new Keyframe(currentTime, currentValue, 'hold'); + ``` + 3. Add Timeline menu options (main.js:5910, in timelineSubmenu): + ```javascript + { + text: "Add Shape Tween", + enabled: /* check if shape is selected and has keyframes */, + action: () => { + // Find shapeIndex curve for selected shape + // Change interpolation between keyframes to "linear" + } + }, + { + text: "Add Motion Tween", + enabled: /* check if object is selected and has transform keyframes */, + action: () => { + // Find position/rotation/scale curves for selected object + // Change interpolation between keyframes to "linear" or "bezier" + } + } + ``` +- **Note**: exists and zOrder keyframes already correctly use "hold" (main.js:2139, 2150) +- **Impact**: High - causes unwanted interpolation, shapes morph unexpectedly, objects move when they shouldn't +- **Priority**: High - fundamental animation behavior is incorrect + +### Tauri Pinch-Zoom on Linux +- **Issue**: Two-finger pinch gestures zoom the entire Tauri window instead of individual canvases +- **Status**: Known Tauri limitation on Linux/GTK with no cross-platform solution +- **Tracking**: https://github.com/tauri-apps/tauri/discussions/3843 +- **Workaround attempts**: Tried `zoomHotkeysEnabled: false`, `touch-action: none`, viewport meta tags - none worked +- **Resolution**: Monitor Tauri releases for official fix + +## Notes + +### Architecture +- **GraphicsObject** contains Layers and has `currentTime` (continuous time) +- **Layer** contains `shapes[]` array and `animationData` (AnimationData instance) +- **AnimationData** contains curves dictionary, each curve identified by parameter name + - Shape curves: `shape.{uuid}.exists`, `shape.{uuid}.zOrder` + - Future: `shape.{uuid}.x`, `shape.{uuid}.y`, `shape.{uuid}.rotation`, etc. +- **Shapes render based on curves**: Layer.draw checks exists > 0, sorts by zOrder, draws in order + +### Interpolation Types +- `linear` - Linear interpolation between keyframes +- `bezier` - Cubic Bezier with easing control points +- `step`/`hold` - Step function (jumps to next value) diff --git a/daw-backend/src/audio/automation.rs b/daw-backend/src/audio/automation.rs index e592905..484c105 100644 --- a/daw-backend/src/audio/automation.rs +++ b/daw-backend/src/audio/automation.rs @@ -189,22 +189,22 @@ mod tests { fn test_add_points_sorted() { let mut lane = AutomationLane::new(0, ParameterId::TrackVolume); - lane.add_point(AutomationPoint::new(2.0, 0.5, CurveType::Linear)); - lane.add_point(AutomationPoint::new(1.0, 0.3, CurveType::Linear)); - lane.add_point(AutomationPoint::new(3.0, 0.8, CurveType::Linear)); + lane.add_point(AutomationPoint::new(Beats(2.0), 0.5, CurveType::Linear)); + lane.add_point(AutomationPoint::new(Beats(1.0), 0.3, CurveType::Linear)); + lane.add_point(AutomationPoint::new(Beats(3.0), 0.8, CurveType::Linear)); assert_eq!(lane.points().len(), 3); - assert_eq!(lane.points()[0].time, 1.0); - assert_eq!(lane.points()[1].time, 2.0); - assert_eq!(lane.points()[2].time, 3.0); + assert_eq!(lane.points()[0].time, Beats(1.0)); + assert_eq!(lane.points()[1].time, Beats(2.0)); + assert_eq!(lane.points()[2].time, Beats(3.0)); } #[test] fn test_replace_point_at_same_time() { let mut lane = AutomationLane::new(0, ParameterId::TrackVolume); - lane.add_point(AutomationPoint::new(1.0, 0.3, CurveType::Linear)); - lane.add_point(AutomationPoint::new(1.0, 0.5, CurveType::Linear)); + lane.add_point(AutomationPoint::new(Beats(1.0), 0.3, CurveType::Linear)); + lane.add_point(AutomationPoint::new(Beats(1.0), 0.5, CurveType::Linear)); assert_eq!(lane.points().len(), 1); assert_eq!(lane.points()[0].value, 0.5); @@ -214,59 +214,59 @@ mod tests { fn test_linear_interpolation() { let mut lane = AutomationLane::new(0, ParameterId::TrackVolume); - lane.add_point(AutomationPoint::new(0.0, 0.0, CurveType::Linear)); - lane.add_point(AutomationPoint::new(1.0, 1.0, CurveType::Linear)); + lane.add_point(AutomationPoint::new(Beats(0.0), 0.0, CurveType::Linear)); + lane.add_point(AutomationPoint::new(Beats(1.0), 1.0, CurveType::Linear)); - assert_eq!(lane.evaluate(0.0), Some(0.0)); - assert_eq!(lane.evaluate(0.5), Some(0.5)); - assert_eq!(lane.evaluate(1.0), Some(1.0)); + assert_eq!(lane.evaluate(Beats(0.0)), Some(0.0)); + assert_eq!(lane.evaluate(Beats(0.5)), Some(0.5)); + assert_eq!(lane.evaluate(Beats(1.0)), Some(1.0)); } #[test] fn test_step_interpolation() { let mut lane = AutomationLane::new(0, ParameterId::TrackVolume); - lane.add_point(AutomationPoint::new(0.0, 0.5, CurveType::Step)); - lane.add_point(AutomationPoint::new(1.0, 1.0, CurveType::Step)); + lane.add_point(AutomationPoint::new(Beats(0.0), 0.5, CurveType::Step)); + lane.add_point(AutomationPoint::new(Beats(1.0), 1.0, CurveType::Step)); - assert_eq!(lane.evaluate(0.0), Some(0.5)); - assert_eq!(lane.evaluate(0.5), Some(0.5)); - assert_eq!(lane.evaluate(0.99), Some(0.5)); - assert_eq!(lane.evaluate(1.0), Some(1.0)); + assert_eq!(lane.evaluate(Beats(0.0)), Some(0.5)); + assert_eq!(lane.evaluate(Beats(0.5)), Some(0.5)); + assert_eq!(lane.evaluate(Beats(0.99)), Some(0.5)); + assert_eq!(lane.evaluate(Beats(1.0)), Some(1.0)); } #[test] fn test_evaluate_outside_range() { let mut lane = AutomationLane::new(0, ParameterId::TrackVolume); - lane.add_point(AutomationPoint::new(1.0, 0.5, CurveType::Linear)); - lane.add_point(AutomationPoint::new(2.0, 1.0, CurveType::Linear)); + lane.add_point(AutomationPoint::new(Beats(1.0), 0.5, CurveType::Linear)); + lane.add_point(AutomationPoint::new(Beats(2.0), 1.0, CurveType::Linear)); // Before first point - assert_eq!(lane.evaluate(0.0), Some(0.5)); + assert_eq!(lane.evaluate(Beats(0.0)), Some(0.5)); // After last point - assert_eq!(lane.evaluate(3.0), Some(1.0)); + assert_eq!(lane.evaluate(Beats(3.0)), Some(1.0)); } #[test] fn test_disabled_lane() { let mut lane = AutomationLane::new(0, ParameterId::TrackVolume); - lane.add_point(AutomationPoint::new(0.0, 0.5, CurveType::Linear)); + lane.add_point(AutomationPoint::new(Beats(0.0), 0.5, CurveType::Linear)); lane.enabled = false; - assert_eq!(lane.evaluate(0.0), None); + assert_eq!(lane.evaluate(Beats(0.0)), None); } #[test] fn test_remove_point() { let mut lane = AutomationLane::new(0, ParameterId::TrackVolume); - lane.add_point(AutomationPoint::new(1.0, 0.5, CurveType::Linear)); - lane.add_point(AutomationPoint::new(2.0, 0.8, CurveType::Linear)); + lane.add_point(AutomationPoint::new(Beats(1.0), 0.5, CurveType::Linear)); + lane.add_point(AutomationPoint::new(Beats(2.0), 0.8, CurveType::Linear)); - assert!(lane.remove_point_at_time(1.0, 0.001)); + assert!(lane.remove_point_at_time(Beats(1.0), Beats(0.001))); assert_eq!(lane.points().len(), 1); - assert_eq!(lane.points()[0].time, 2.0); + assert_eq!(lane.points()[0].time, Beats(2.0)); } } diff --git a/daw-backend/src/audio/disk_reader.rs b/daw-backend/src/audio/disk_reader.rs index 377f4f0..f810667 100644 --- a/daw-backend/src/audio/disk_reader.rs +++ b/daw-backend/src/audio/disk_reader.rs @@ -307,23 +307,96 @@ impl ReadAheadBuffer { // --------------------------------------------------------------------------- /// Wraps a Symphonia decoder for streaming a single compressed audio file. -struct CompressedReader { +/// +/// Public (like [`VideoAudioReader`]) only so integration tests can exercise it +/// directly; treat it as crate-internal. +pub struct CompressedReader { format_reader: Box, decoder: Box, track_id: u32, /// Current decoder position in frames. current_frame: u64, + /// Frames still to drop from the front of decoded output, so that after a + /// (coarse) seek the next emitted sample lands exactly on the target frame. + pending_discard: u64, sample_rate: u32, channels: u32, - #[allow(dead_code)] total_frames: u64, /// Temporary decode buffer. sample_buf: Option>, } +/// A seekable byte stream for packed media held in the host's project container. +/// +/// `daw-backend` stays container-agnostic: it never references the `.beam` SQLite +/// store directly. Instead the host (lightningbeam-core) implements this trait over +/// its incremental blob reader and installs a factory ([`AudioBlobSourceFactory`]) +/// into the engine, so packed compressed audio can be stream-decoded without ever +/// being fully loaded into RAM. +pub trait MediaByteSource: std::io::Read + std::io::Seek + Send + Sync { + /// Total length of the stream in bytes (Symphonia needs this for seeking). + fn byte_len(&self) -> u64; +} + +/// Opens fresh byte streams for packed media by id. Installed into the engine by +/// the host; invoked when activating a clip backed by container-packed audio. +/// (`Debug` so it can ride in the `Query` enum, which derives `Debug`.) +pub trait AudioBlobSourceFactory: Send + Sync + std::fmt::Debug { + /// Open a new independent reader for the packed media item `media_id` + /// (the UUID string stored on the audio pool entry). + fn open(&self, media_id: &str) -> Result, String>; +} + +/// Adapts a [`MediaByteSource`] to Symphonia's `MediaSource` (adds the seekable + +/// byte-length metadata Symphonia's probe/seek require). +struct SymphoniaByteSource(Box); + +impl std::io::Read for SymphoniaByteSource { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.0.read(buf) + } +} +impl std::io::Seek for SymphoniaByteSource { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + self.0.seek(pos) + } +} +impl symphonia::core::io::MediaSource for SymphoniaByteSource { + fn is_seekable(&self) -> bool { + true + } + fn byte_len(&self) -> Option { + Some(self.0.byte_len()) + } +} + +/// How to open a streaming audio source: a filesystem path (referenced media or a +/// video file) or a host-provided byte stream (container-packed media). +pub enum StreamOpen { + Path(PathBuf), + Source { + src: Box, + /// Codec/extension hint for the Symphonia probe (e.g. `"mp3"`, `"flac"`). + ext: Option, + }, +} + impl CompressedReader { + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + pub fn channels(&self) -> u32 { + self.channels + } + + /// Total frames from the codec header (0 if the format doesn't report it). + pub fn total_frames(&self) -> u64 { + self.total_frames + } + /// Open a compressed audio file and prepare for streaming decode. - fn open(path: &Path) -> Result { + pub fn open(path: &Path) -> Result { let file = std::fs::File::open(path).map_err(|e| format!("Failed to open file: {}", e))?; let mss = MediaSourceStream::new(Box::new(file), Default::default()); @@ -332,7 +405,21 @@ impl CompressedReader { if let Some(ext) = path.extension().and_then(|e| e.to_str()) { hint.with_extension(ext); } + Self::from_mss(mss, hint) + } + /// Open a compressed stream from a host-provided byte source (packed media). + pub fn open_source(src: Box, ext: Option<&str>) -> Result { + let mss = MediaSourceStream::new(Box::new(SymphoniaByteSource(src)), Default::default()); + let mut hint = Hint::new(); + if let Some(ext) = ext { + hint.with_extension(ext); + } + Self::from_mss(mss, hint) + } + + /// Shared probe + decoder setup over an already-built media stream. + fn from_mss(mss: MediaSourceStream, hint: Hint) -> Result { let probed = symphonia::default::get_probe() .format( &hint, @@ -368,6 +455,7 @@ impl CompressedReader { decoder, track_id, current_frame: 0, + pending_discard: 0, sample_rate, channels, total_frames, @@ -375,9 +463,17 @@ impl CompressedReader { }) } - /// Seek to a specific frame. Returns the actual frame reached (may differ - /// for compressed formats that can only seek to keyframes). - fn seek(&mut self, target_frame: u64) -> Result { + /// Seek to `target_frame`, **sample-accurately**. Uses `SeekMode::Accurate`: + /// for an elementary stream like MP3 a *coarse* seek byte-estimates the + /// position and seeds the timestamp from that estimate — which for VBR (or a + /// file whose header padding the estimate ignores) lands off by up to ~1s. + /// Accurate mode instead counts frame *headers* (no decode) from a true anchor + /// (the current position, or a rewind to the start for backward seeks), so the + /// returned `actual_ts` is exact; the small residual to `target_frame` is then + /// dropped in `decode_next`. Container formats with seek tables (FLAC/OGG) seek + /// cheaply; a long MP3 walks headers from the anchor (I/O, not decode) — a + /// per-file seek index would make that O(1) (future work). + pub fn seek(&mut self, target_frame: u64) -> Result { let seek_to = SeekTo::TimeStamp { ts: target_frame, track_id: self.track_id, @@ -385,21 +481,23 @@ impl CompressedReader { let seeked = self .format_reader - .seek(SeekMode::Coarse, seek_to) + .seek(SeekMode::Accurate, seek_to) .map_err(|e| format!("Seek failed: {}", e))?; let actual_frame = seeked.actual_ts; self.current_frame = actual_frame; + // Drop the frames between where the coarse seek landed and the target. + self.pending_discard = target_frame.saturating_sub(actual_frame); // Reset the decoder after seeking. self.decoder.reset(); - Ok(actual_frame) + Ok(target_frame) } /// Decode the next chunk of audio into `out`. Returns the number of frames /// decoded. Returns `Ok(0)` at end-of-file. - fn decode_next(&mut self, out: &mut Vec) -> Result { + pub fn decode_next(&mut self, out: &mut Vec) -> Result { out.clear(); loop { @@ -428,10 +526,22 @@ impl CompressedReader { if let Some(ref mut buf) = self.sample_buf { buf.copy_interleaved_ref(decoded); let samples = buf.samples(); - out.extend_from_slice(samples); - let frames = samples.len() / self.channels as usize; - self.current_frame += frames as u64; - return Ok(frames); + let ch = self.channels as usize; + let frames = samples.len() / ch; + + // Drop leading frames for sample-accurate seek alignment. + let discard = self.pending_discard.min(frames as u64) as usize; + self.pending_discard -= discard as u64; + out.extend_from_slice(&samples[discard * ch..]); + let emitted = frames - discard; + self.current_frame += emitted as u64; + + if emitted > 0 { + return Ok(emitted); + } + // Whole packet discarded for alignment — keep decoding so + // we never falsely report EOF (Ok(0)). + continue; } return Ok(0); @@ -445,16 +555,383 @@ impl CompressedReader { } } +// --------------------------------------------------------------------------- +// VideoAudioReader +// --------------------------------------------------------------------------- + +/// Streams the audio track out of a media file (a video container, or any audio +/// file) using FFmpeg, decoding on demand. Mirrors [`CompressedReader`]'s +/// interface so the disk reader can drive either through [`StreamSource`]. +/// +/// Seeking is **sample-accurate**: after `seek(target)`, the next `decode_next` +/// yields samples beginning at exactly `target`. FFmpeg's container seek only +/// lands at-or-before the target, so we decode forward and discard the leading +/// samples to hit the frame precisely — this keeps video audio frame-synced with +/// other (mmap/in-memory) clips. +/// +/// Public (vs. the private `CompressedReader`) only so integration tests can +/// exercise it directly; treat it as crate-internal. +pub struct VideoAudioReader { + input: ffmpeg_next::format::context::Input, + decoder: ffmpeg_next::decoder::Audio, + /// Built lazily from the first decoded frame's format/layout → interleaved f32. + resampler: Option, + stream_index: usize, + /// Seconds per stream-timestamp unit. + time_base: f64, + sample_rate: u32, + channels: u32, + total_frames: u64, + /// Absolute frame index of the next sample `decode_next` will output. + current_frame: u64, + /// Frames still to drop from the front of decoded output (seek alignment). + pending_discard: u64, + /// When set, the next decoded frame establishes the discard needed to land on + /// this absolute target frame (sample-accurate seek). + align_to: Option, +} + +impl VideoAudioReader { + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } + + pub fn channels(&self) -> u32 { + self.channels + } + + /// Estimated total audio frames (from the stream/container duration). + pub fn total_frames(&self) -> u64 { + self.total_frames + } + + pub fn open(path: &Path) -> Result { + ffmpeg_next::init().map_err(|e| e.to_string())?; + let input = ffmpeg_next::format::input(&path) + .map_err(|e| format!("Failed to open media: {}", e))?; + + // Pull stream scalars + build the decoder inside a scope so the stream + // borrow of `input` ends before we use `input` again. + let (stream_index, time_base, stream_duration, decoder) = { + let stream = input + .streams() + .best(ffmpeg_next::media::Type::Audio) + .ok_or_else(|| "No audio stream found".to_string())?; + let stream_index = stream.index(); + let time_base = f64::from(stream.time_base()); + let stream_duration = stream.duration(); + let ctx = ffmpeg_next::codec::context::Context::from_parameters(stream.parameters()) + .map_err(|e| e.to_string())?; + let decoder = ctx.decoder().audio().map_err(|e| e.to_string())?; + (stream_index, time_base, stream_duration, decoder) + }; + + let sample_rate = decoder.rate(); + let channels = decoder.channels() as u32; + + let duration_secs = if stream_duration > 0 { + stream_duration as f64 * time_base + } else if input.duration() > 0 { + input.duration() as f64 / f64::from(ffmpeg_next::ffi::AV_TIME_BASE) + } else { + 0.0 + }; + let total_frames = (duration_secs * sample_rate as f64).max(0.0) as u64; + + Ok(Self { + input, + decoder, + resampler: None, + stream_index, + time_base, + sample_rate, + channels, + total_frames, + current_frame: 0, + pending_discard: 0, + align_to: None, + }) + } + + pub fn seek(&mut self, target_frame: u64) -> Result { + let seconds = target_frame as f64 / self.sample_rate.max(1) as f64; + let ts_av = (seconds * f64::from(ffmpeg_next::ffi::AV_TIME_BASE)) as i64; + // Seek to at-or-before the target (max_ts = ts_av) so we can decode + // forward to it exactly. ffmpeg-next's `seek` wants a bounded range. + self.input + .seek(ts_av, 0..(ts_av + 1)) + .map_err(|e| format!("Seek failed: {}", e))?; + self.decoder.flush(); + self.pending_discard = 0; + self.align_to = Some(target_frame); + self.current_frame = target_frame; + // We align to the exact frame below, so the effective position IS target. + Ok(target_frame) + } + + pub fn decode_next(&mut self, out: &mut Vec) -> Result { + out.clear(); + loop { + // Drain a decoded frame if one is ready. + let mut decoded = ffmpeg_next::frame::Audio::empty(); + if self.decoder.receive_frame(&mut decoded).is_ok() { + self.ensure_layout(&mut decoded); + let n = self.emit(&decoded, out); + if n > 0 { + return Ok(n); + } + continue; // frame fully discarded by seek-alignment; keep going + } + + // Read one packet (owned), releasing the `input` borrow before decoding. + let packet = self.input.packets().next().map(|(_, p)| p); + match packet { + Some(packet) => { + if packet.stream() == self.stream_index { + self.decoder + .send_packet(&packet) + .map_err(|e| e.to_string())?; + } + } + None => { + // EOF: flush and drain whatever remains. + let _ = self.decoder.send_eof(); + let mut decoded = ffmpeg_next::frame::Audio::empty(); + if self.decoder.receive_frame(&mut decoded).is_ok() { + self.ensure_layout(&mut decoded); + return Ok(self.emit(&decoded, out)); + } + return Ok(0); + } + } + } + } + + /// Decoders for some formats (e.g. raw mono WAV) leave the frame's channel + /// layout unset. The resampler needs a concrete layout that matches the + /// frame, so fill one in from the channel count when it's missing. + fn ensure_layout(&self, frame: &mut ffmpeg_next::frame::Audio) { + if frame.channel_layout().is_empty() { + frame.set_channel_layout( + ffmpeg_next::channel_layout::ChannelLayout::default(self.channels as i32), + ); + } + } + + /// Resample one decoded frame to interleaved f32, apply any pending + /// seek-alignment discard, append to `out`, return frames emitted. + fn emit(&mut self, frame: &ffmpeg_next::frame::Audio, out: &mut Vec) -> usize { + // `frame` already has a non-empty channel layout (set by `ensure_layout` + // before this call), so the resampler config and the actual frame agree + // — otherwise swr fails with AVERROR_INPUT_CHANGED. + if self.resampler.is_none() { + match ffmpeg_next::software::resampling::Context::get( + frame.format(), + frame.channel_layout(), + self.sample_rate, + ffmpeg_next::format::Sample::F32(ffmpeg_next::format::sample::Type::Packed), + frame.channel_layout(), + self.sample_rate, + ) { + Ok(r) => self.resampler = Some(r), + Err(_) => return 0, + } + } + + let mut resampled = ffmpeg_next::frame::Audio::empty(); + if self + .resampler + .as_mut() + .unwrap() + .run(frame, &mut resampled) + .is_err() + { + return 0; + } + + // The output is packed (interleaved) f32. Read it from the raw byte plane + // `data(0)` — its length is correct (`frames * channels * 4`), whereas + // `plane::(0)` is a known ffmpeg-next footgun that reports only + // `samples()` elements (ignoring channels) and would slice out of range + // for multi-channel audio. + let ch = self.channels.max(1) as usize; + let bytes = resampled.data(0); + let n_frames = (bytes.len() / 4) / ch; + if n_frames == 0 { + return 0; + } + + // On the first frame after a seek, compute how many leading frames to + // drop so output begins exactly at the seek target. + if let Some(target) = self.align_to.take() { + let frame_start = self.pts_to_frame(frame.pts()); + self.pending_discard = target.saturating_sub(frame_start); + } + + let discard = (self.pending_discard.min(n_frames as u64)) as usize; + self.pending_discard -= discard as u64; + + let start_byte = discard * ch * 4; + let end_byte = n_frames * ch * 4; + out.extend( + bytes[start_byte..end_byte] + .chunks_exact(4) + .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])), + ); + let emitted = n_frames - discard; + self.current_frame += emitted as u64; + emitted + } + + /// Convert a stream PTS to an absolute audio frame index. + fn pts_to_frame(&self, pts: Option) -> u64 { + match pts { + Some(p) if p >= 0 => { + ((p as f64 * self.time_base) * self.sample_rate as f64).round() as u64 + } + _ => self.current_frame, + } + } +} + +// --------------------------------------------------------------------------- +// StreamSource — dispatches the disk reader over either decoder backend. +// (Wired into the reader thread in a later step.) +// --------------------------------------------------------------------------- + +/// Which decoder backend a streaming source uses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceKind { + /// Symphonia, for compressed audio files (MP3, FLAC, OGG, …). + CompressedAudio, + /// FFmpeg, for the audio track of a video container. + VideoAudio, +} + +/// A streaming audio source backing one active clip: either Symphonia +/// ([`CompressedReader`]) or FFmpeg ([`VideoAudioReader`]). +enum StreamSource { + Compressed(CompressedReader), + Video(VideoAudioReader), +} + +impl StreamSource { + fn open(open: StreamOpen, kind: SourceKind) -> Result { + match (kind, open) { + (SourceKind::CompressedAudio, StreamOpen::Path(p)) => { + Ok(StreamSource::Compressed(CompressedReader::open(&p)?)) + } + (SourceKind::CompressedAudio, StreamOpen::Source { src, ext }) => { + Ok(StreamSource::Compressed(CompressedReader::open_source(src, ext.as_deref())?)) + } + (SourceKind::VideoAudio, StreamOpen::Path(p)) => { + Ok(StreamSource::Video(VideoAudioReader::open(&p)?)) + } + (SourceKind::VideoAudio, StreamOpen::Source { .. }) => { + Err("VideoAudio cannot be opened from a packed byte source".to_string()) + } + } + } + + fn sample_rate(&self) -> u32 { + match self { + StreamSource::Compressed(r) => r.sample_rate, + StreamSource::Video(r) => r.sample_rate, + } + } + + fn channels(&self) -> u32 { + match self { + StreamSource::Compressed(r) => r.channels, + StreamSource::Video(r) => r.channels, + } + } + + fn seek(&mut self, target_frame: u64) -> Result { + match self { + StreamSource::Compressed(r) => r.seek(target_frame), + StreamSource::Video(r) => r.seek(target_frame), + } + } + + fn decode_next(&mut self, out: &mut Vec) -> Result { + match self { + StreamSource::Compressed(r) => r.decode_next(out), + StreamSource::Video(r) => r.decode_next(out), + } + } + + fn total_frames(&self) -> u64 { + match self { + StreamSource::Compressed(r) => r.total_frames(), + StreamSource::Video(r) => r.total_frames(), + } + } +} + +/// Decode a media source end-to-end and build its [`WaveformPyramid`] overview, +/// streaming — only one decode chunk plus the (bounded) pyramid are ever in +/// memory, never the full sample buffer. `floor_samples_per_texel` is the +/// finest-level resolution (see [`crate::audio::waveform_pyramid`]). +pub fn build_waveform_pyramid( + path: &Path, + kind: SourceKind, + floor_samples_per_texel: u32, +) -> Result { + let src = StreamSource::open(StreamOpen::Path(path.to_path_buf()), kind)?; + build_pyramid_from_streamsource(src, floor_samples_per_texel) +} + +/// Build a waveform pyramid from a host-provided byte source (container-packed +/// compressed audio) — the load-time counterpart of [`build_waveform_pyramid`] +/// for media that has no filesystem path. +pub fn build_waveform_pyramid_from_source( + src: Box, + ext: Option<&str>, + floor_samples_per_texel: u32, +) -> Result { + let src = StreamSource::open( + StreamOpen::Source { src, ext: ext.map(|s| s.to_string()) }, + SourceKind::CompressedAudio, + )?; + build_pyramid_from_streamsource(src, floor_samples_per_texel) +} + +fn build_pyramid_from_streamsource( + mut src: StreamSource, + floor_samples_per_texel: u32, +) -> Result { + use crate::audio::waveform_pyramid::WaveformPyramidBuilder; + + let channels = src.channels(); + let mut builder = WaveformPyramidBuilder::new(channels, floor_samples_per_texel); + builder.reserve_for_frames(src.total_frames()); + + let mut buf = Vec::new(); + loop { + let frames = src.decode_next(&mut buf)?; + if frames == 0 { + break; + } + builder.push_interleaved(&buf); + } + Ok(builder.finish()) +} + // --------------------------------------------------------------------------- // DiskReaderCommand // --------------------------------------------------------------------------- /// Commands sent from the engine to the disk reader thread. pub enum DiskReaderCommand { - /// Start streaming a compressed file for a clip instance. + /// Start streaming a file for a clip instance, using the decoder backend + /// selected by `kind` (compressed audio vs. a video's audio track). `open` + /// is either a filesystem path (referenced media / video) or a host-provided + /// byte stream (container-packed media). ActivateFile { reader_id: u64, - path: PathBuf, + open: StreamOpen, + kind: SourceKind, buffer: Arc, }, /// Stop streaming for a clip instance. @@ -529,7 +1006,7 @@ impl DiskReader { mut command_rx: rtrb::Consumer, running: Arc, ) { - let mut active_files: HashMap)> = + let mut active_files: HashMap)> = HashMap::new(); let mut decode_buf = Vec::with_capacity(8192); @@ -539,18 +1016,19 @@ impl DiskReader { match cmd { DiskReaderCommand::ActivateFile { reader_id, - path, + open, + kind, buffer, - } => match CompressedReader::open(&path) { + } => match StreamSource::open(open, kind) { Ok(reader) => { - eprintln!("[DiskReader] Activated reader={}, ch={}, sr={}, path={:?}", - reader_id, reader.channels, reader.sample_rate, path); + eprintln!("[DiskReader] Activated reader={}, kind={:?}, ch={}, sr={}", + reader_id, kind, reader.channels(), reader.sample_rate()); active_files.insert(reader_id, (reader, buffer)); } Err(e) => { eprintln!( - "[DiskReader] Failed to open compressed file {:?}: {}", - path, e + "[DiskReader] Failed to open reader={} ({:?}): {}", + reader_id, kind, e ); } }, @@ -588,7 +1066,7 @@ impl DiskReader { // If the target has jumped behind or far ahead of the buffer, // seek the decoder and reset. - if target < buf_start || target > buf_end + reader.sample_rate as u64 { + if target < buf_start || target > buf_end + reader.sample_rate() as u64 { buffer.reset(target); let _ = reader.seek(target); continue; @@ -607,7 +1085,7 @@ impl DiskReader { let buf_valid = buffer.valid_frames_count(); let buf_end = buf_start + buf_valid; let prefetch_target = - target + (PREFETCH_SECONDS * reader.sample_rate as f64) as u64; + target + (PREFETCH_SECONDS * reader.sample_rate() as f64) as u64; if buf_end >= prefetch_target { continue; // Already filled far enough ahead. @@ -649,3 +1127,7 @@ impl Drop for DiskReader { } } } + +// Tests for VideoAudioReader live in `daw-backend/tests/video_audio_stream.rs` +// (integration tests) so they build the lib in normal mode, independent of +// pre-existing breakage in the crate's `#[cfg(test)]` unit tests (automation.rs). diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 8d59698..4991346 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -91,6 +91,10 @@ pub struct Engine { // Disk reader for streaming playback of compressed files disk_reader: Option, + // Host-installed factory for opening container-packed audio as a byte stream + // (set on load, before the project's clips are bulk-activated for streaming). + blob_source_factory: Option>, + // Input monitoring and metering input_monitoring: bool, input_gain: f32, @@ -176,6 +180,7 @@ impl Engine { metronome: Metronome::new(sample_rate), recording_sample_buffer: Vec::with_capacity(4096), disk_reader: Some(disk_reader), + blob_source_factory: None, input_monitoring: false, input_gain: 1.0, input_level_peak: 0.0, @@ -273,6 +278,97 @@ impl Engine { /// Rebuild the clip snapshot from the current project state. /// Call this after any command that adds, removes, or modifies clip instances. + /// Set up disk streaming for a clip backed by a streaming pool entry + /// (Compressed or VideoAudio). Sends `ActivateFile` to the disk reader and + /// returns the read-ahead buffer to attach to the clip. Returns `None` if the + /// entry isn't streamed, or a packed source can't be opened. + fn activate_streaming_for( + &mut self, + reader_id: u64, + pool_index: usize, + ) -> Option> { + use crate::audio::pool::AudioStorage; + use crate::audio::disk_reader::{DiskReader, DiskReaderCommand, SourceKind, StreamOpen}; + + // Decide how to open the source. `Packed` ⇒ via the host factory (bytes in + // the container); otherwise stream from the file path. Extract owned values + // first so the immutable pool borrow ends before we touch the disk reader. + enum OpenDesc { + Path(std::path::PathBuf), + Packed { media_id: String, ext: Option }, + } + let (kind, sample_rate, channels, desc) = { + let file = self.audio_pool.get_file(pool_index)?; + let kind = match file.storage { + AudioStorage::Compressed { .. } => SourceKind::CompressedAudio, + AudioStorage::VideoAudio { .. } => SourceKind::VideoAudio, + _ => return None, + }; + let desc = match &file.packed_media_id { + Some(id) => OpenDesc::Packed { media_id: id.clone(), ext: file.original_format.clone() }, + None => OpenDesc::Path(file.path.clone()), + }; + (kind, file.sample_rate, file.channels, desc) + }; + + let open = match desc { + OpenDesc::Path(p) => StreamOpen::Path(p), + OpenDesc::Packed { media_id, ext } => { + let factory = match self.blob_source_factory.as_ref() { + Some(f) => f, + None => { + eprintln!("[Engine] packed audio (pool {}) but no blob factory installed", pool_index); + return None; + } + }; + match factory.open(&media_id) { + Ok(src) => StreamOpen::Source { src, ext }, + Err(e) => { + eprintln!("[Engine] blob factory open({}) failed: {}", media_id, e); + return None; + } + } + } + }; + + let buffer = DiskReader::create_buffer(sample_rate, channels); + if let Some(ref mut dr) = self.disk_reader { + dr.send(DiskReaderCommand::ActivateFile { reader_id, open, kind, buffer: buffer.clone() }); + } + Some(buffer) + } + + /// Activate disk streaming for every loaded clip backed by a streaming pool + /// entry. Called after `SetProject` since loaded clips bypass `AddAudioClip`. + fn activate_all_streaming_clips(&mut self) { + use crate::audio::track::TrackNode; + // Collect (track, clip, pool) first via an immutable walk, then activate + // (needs &mut self) and attach the buffer back to the clip. + let targets: Vec<(TrackId, u64, usize)> = self + .project + .track_iter() + .filter_map(|(track_id, node)| match node { + TrackNode::Audio(t) => Some((track_id, t)), + _ => None, + }) + .flat_map(|(track_id, t)| { + t.clips + .iter() + .map(move |c| (track_id, c.id as u64, c.audio_pool_index)) + }) + .collect(); + + for (track_id, clip_id, pool_index) in targets { + if let Some(buffer) = self.activate_streaming_for(clip_id, pool_index) { + if let Some(TrackNode::Audio(track)) = self.project.get_track_mut(track_id) { + if let Some(clip) = track.clips.iter_mut().find(|c| c.id as u64 == clip_id) { + clip.read_ahead = Some(buffer); + } + } + } + } + } + fn refresh_clip_snapshot(&self) { let mut snap = self.clip_snapshot.write().unwrap(); snap.audio.clear(); @@ -914,7 +1010,7 @@ impl Engine { let start_secs = self.tempo_map.beats_to_seconds(start_beats); let end_secs = self.tempo_map.beats_to_seconds(end_beats); let content_dur_secs = (end_secs - start_secs).seconds_to_f64(); - let clip = AudioClipInstance::new( + let mut clip = AudioClipInstance::new( clip_id, pool_index, Seconds(offset), @@ -923,6 +1019,13 @@ impl Engine { Beats(duration), ); + // If the source is streamed (a compressed audio file, or a video's + // audio track), set up disk streaming instead of an in-memory decode. + // Each clip instance gets its own read-ahead buffer keyed by clip_id. + if let Some(buffer) = self.activate_streaming_for(clip_id as u64, pool_index) { + clip.read_ahead = Some(buffer); + } + // Add clip to track if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) { track.clips.push(clip); @@ -2313,6 +2416,34 @@ impl Engine { } } + /// Add a video file's audio track as a streaming pool entry (FFmpeg, decoded + /// on demand — no extraction). Probes the audio track for channels/rate/frames + /// without decoding, and returns the pool index. Playback activation + /// (per-clip read-ahead) is wired separately when a clip references it. + fn do_add_video_audio(&mut self, path: &std::path::Path) -> Result { + use crate::audio::disk_reader::VideoAudioReader; + + let reader = VideoAudioReader::open(path)?; + let channels = reader.channels(); + let sample_rate = reader.sample_rate(); + let total_frames = reader.total_frames(); + drop(reader); + + let audio_file = crate::audio::pool::AudioFile::from_video_audio( + path.to_path_buf(), + channels, + sample_rate, + total_frames, + ); + let pool_index = self.audio_pool.add_file(audio_file); + + eprintln!( + "[ENGINE] AddVideoAudio: ch={}, sr={}, total_frames={}, pool_index={}, path={:?}", + channels, sample_rate, total_frames, pool_index, path + ); + Ok(pool_index) + } + /// Import an audio file into the pool: mmap for PCM, streaming for compressed. /// Returns the pool index on success. Emits AudioFileReady event. fn do_import_audio(&mut self, path: &std::path::Path) -> Result { @@ -2738,10 +2869,14 @@ impl Engine { Query::GetPoolAudioSamples(pool_index) => { match self.audio_pool.get_file(pool_index) { Some(file) => { - // For Compressed storage, return decoded_for_waveform if available + // For streamed (Compressed/VideoAudio) storage, return the + // progressively-decoded waveform overview if available. let samples = match &file.storage { crate::audio::pool::AudioStorage::Compressed { decoded_for_waveform, decoded_frames, .. + } + | crate::audio::pool::AudioStorage::VideoAudio { + decoded_for_waveform, decoded_frames, .. } if *decoded_frames > 0 => { decoded_for_waveform.clone() } @@ -2794,77 +2929,12 @@ impl Engine { self.refresh_clip_snapshot(); result } - Query::AddAudioFileSync(path, data, channels, sample_rate) => { - // Add audio file to pool and return the pool index - // Detect original format from file extension - let path_buf = std::path::PathBuf::from(&path); - let original_format = path_buf.extension() - .and_then(|ext| ext.to_str()) - .map(|s| s.to_lowercase()); - - // Create AudioFile and add to pool - let audio_file = crate::audio::pool::AudioFile::with_format( - path_buf.clone(), - data.clone(), // Clone data for background thread - channels, - sample_rate, - original_format, - ); - let pool_index = self.audio_pool.add_file(audio_file); - - // Generate Level 0 (overview) waveform chunks asynchronously in background thread - let chunk_tx = self.chunk_generation_tx.clone(); - let duration = data.len() as f64 / (sample_rate as f64 * channels as f64); - println!("🔄 [ENGINE] Spawning background thread to generate Level 0 chunks for pool {}", pool_index); - std::thread::spawn(move || { - // Create temporary AudioFile for chunk generation - let temp_audio_file = crate::audio::pool::AudioFile::with_format( - path_buf, - data, - channels, - sample_rate, - None, - ); - - // Generate Level 0 chunks - let chunk_count = crate::audio::waveform_cache::WaveformCache::calculate_chunk_count(duration, 0); - println!("🔄 [BACKGROUND] Generating {} Level 0 chunks for pool {}", chunk_count, pool_index); - let chunks = crate::audio::waveform_cache::WaveformCache::generate_chunks( - &temp_audio_file, - pool_index, - 0, // Level 0 (overview) - &(0..chunk_count).collect::>(), - ); - - // Send chunks via MPSC channel (will be forwarded by audio thread) - if !chunks.is_empty() { - println!("📤 [BACKGROUND] Generated {} chunks, sending to audio thread (pool {})", chunks.len(), pool_index); - let event_chunks: Vec<(u32, (f64, f64), Vec)> = chunks - .into_iter() - .map(|chunk| (chunk.chunk_index, chunk.time_range, chunk.peaks)) - .collect(); - - match chunk_tx.send(AudioEvent::WaveformChunksReady { - pool_index, - detail_level: 0, - chunks: event_chunks, - }) { - Ok(_) => println!("✅ [BACKGROUND] Chunks sent successfully for pool {}", pool_index), - Err(e) => eprintln!("❌ [BACKGROUND] Failed to send chunks: {}", e), - } - } else { - eprintln!("⚠️ [BACKGROUND] No chunks generated for pool {}", pool_index); - } - }); - - // Notify UI about the new audio file (for event listeners) - let _ = self.event_tx.push(AudioEvent::AudioFileAdded(pool_index, path)); - - QueryResponse::AudioFileAddedSync(Ok(pool_index)) - } Query::ImportAudioSync(path) => { QueryResponse::AudioImportedSync(self.do_import_audio(&path)) } + Query::AddVideoAudioSync(path) => { + QueryResponse::AudioImportedSync(self.do_add_video_audio(&path)) + } Query::GetProject => { // Save graph presets before cloning — AudioTrack::clone() creates // a fresh default graph (not a copy), so the preset must be populated @@ -2879,11 +2949,19 @@ impl Engine { match project.rebuild_audio_graphs(self.buffer_pool.buffer_size()) { Ok(()) => { self.project = project; + // Loaded clips bypass AddAudioClip, so their disk streaming was + // never activated — do it now for every streaming-backed clip. + self.activate_all_streaming_clips(); + self.refresh_clip_snapshot(); QueryResponse::ProjectSet(Ok(())) } Err(e) => QueryResponse::ProjectSet(Err(format!("Failed to rebuild audio graphs: {}", e))), } } + Query::SetBlobSourceFactory(factory) => { + self.blob_source_factory = Some(factory); + QueryResponse::BlobSourceFactorySet(Ok(())) + } Query::DuplicateMidiClipSync(clip_id) => { match self.project.midi_clip_pool.duplicate_clip(clip_id) { Some(new_id) => QueryResponse::MidiClipDuplicated(Ok(new_id)), @@ -3395,16 +3473,6 @@ impl EngineController { } } - /// Add an audio file to the pool synchronously and get the pool index - /// Returns the pool index where the audio file was added - pub fn add_audio_file_sync(&mut self, path: String, data: Vec, channels: u32, sample_rate: u32) -> Result { - let query = Query::AddAudioFileSync(path, data, channels, sample_rate); - match self.send_query(query)? { - QueryResponse::AudioFileAddedSync(result) => result, - _ => Err("Unexpected query response".to_string()), - } - } - /// Import an audio file asynchronously. The engine will memory-map WAV/AIFF /// files for instant availability, or set up stream decoding for compressed /// formats. Listen for `AudioEvent::AudioFileReady` to get the pool index. @@ -3427,6 +3495,30 @@ impl EngineController { } } + /// Add a video file's audio track as a streaming pool entry (decoded on + /// demand via FFmpeg — no extraction to disk or RAM). Probes the audio track + /// and returns the pool index. Use this for a video clip's embedded audio. + pub fn add_video_audio_sync(&mut self, path: std::path::PathBuf) -> Result { + let query = Query::AddVideoAudioSync(path); + match self.send_query(query)? { + QueryResponse::AudioImportedSync(result) => result, + _ => Err("Unexpected query response".to_string()), + } + } + + /// Install the host's packed-media byte-source factory. Must be called before + /// loading a project so its container-packed audio can be streamed (the disk + /// reader opens packed entries through this factory). + pub fn set_blob_source_factory( + &mut self, + factory: std::sync::Arc, + ) -> Result<(), String> { + match self.send_query(Query::SetBlobSourceFactory(factory))? { + QueryResponse::BlobSourceFactorySet(result) => result, + _ => Err("Unexpected query response".to_string()), + } + } + /// Generate the next unique audio clip instance ID (atomic, thread-safe) pub fn next_audio_clip_id(&self) -> AudioClipInstanceId { self.next_audio_clip_id.fetch_add(1, Ordering::Relaxed) diff --git a/daw-backend/src/audio/export.rs b/daw-backend/src/audio/export.rs index bdc940b..83d3858 100644 --- a/daw-backend/src/audio/export.rs +++ b/daw-backend/src/audio/export.rs @@ -671,14 +671,39 @@ fn convert_chunk_to_planar_i16(interleaved: &[f32], channels: u32) -> Vec Vec> { let num_frames = interleaved.len() / channels as usize; let mut planar = vec![vec![0.0f32; num_frames]; channels as usize]; + let mut non_finite = 0u64; for (i, chunk) in interleaved.chunks(channels as usize).enumerate() { for (ch, &sample) in chunk.iter().enumerate() { - planar[ch][i] = sample; + planar[ch][i] = if sample.is_finite() { + sample.clamp(-1.0, 1.0) + } else { + non_finite += 1; + 0.0 + }; + } + } + if non_finite > 0 { + // One-time warning: we sanitized rather than failed, but a non-finite + // sample reaching here means something upstream (an effect, automation, + // or a source decode) produced NaN/Inf — worth chasing if audio is wrong. + use std::sync::atomic::{AtomicBool, Ordering}; + static WARNED: AtomicBool = AtomicBool::new(false); + if !WARNED.swap(true, Ordering::Relaxed) { + eprintln!( + "⚠️ [EXPORT] sanitized {} non-finite (NaN/Inf) audio sample(s) in a chunk — \ + check effects/automation/source decode", + non_finite + ); } } diff --git a/daw-backend/src/audio/mod.rs b/daw-backend/src/audio/mod.rs index 6d7d771..8d79ad8 100644 --- a/daw-backend/src/audio/mod.rs +++ b/daw-backend/src/audio/mod.rs @@ -15,10 +15,12 @@ pub mod recording; pub mod sample_loader; pub mod track; pub mod waveform_cache; +pub mod waveform_pyramid; pub use automation::{AutomationLane, AutomationLaneId, AutomationPoint, CurveType, ParameterId}; pub use buffer_pool::BufferPool; pub use clip::{AudioClipInstance, AudioClipInstanceId, Clip, ClipId}; +pub use disk_reader::{AudioBlobSourceFactory, MediaByteSource}; pub use engine::{AudioClipSnapshot, Engine, EngineController}; pub use export::{export_audio, ExportFormat, ExportSettings}; pub use metronome::Metronome; diff --git a/daw-backend/src/audio/pool.rs b/daw-backend/src/audio/pool.rs index ecf0745..7a90f50 100644 --- a/daw-backend/src/audio/pool.rs +++ b/daw-backend/src/audio/pool.rs @@ -4,6 +4,46 @@ use std::f32::consts::PI; use serde::{Deserialize, Serialize}; use crate::time::Seconds; +/// Per-output-channel mix coefficients to fold a multichannel source down to +/// stereo, indexed `[out_channel(0=L,1=R)][src_channel]`. +/// +/// Assumes the conventional interleave order for each channel count (FL, FR, FC, +/// LFE, BL, BR, SL, SR …). Uses standard ITU/AC-3-style coefficients: full level +/// for the matching front channel, `1/√2` (≈ −3 dB) for centre and each surround, +/// LFE dropped. Each row is then normalized so its absolute-coefficient sum ≤ 1, +/// which prevents clipping (matching FFmpeg's default `normalize` behaviour). +/// +/// Returns `None` for layouts we don't special-case (caller falls back to taking +/// the front L/R pair). +fn stereo_downmix_matrix(src_channels: usize) -> Option<[Vec; 2]> { + const C: f32 = std::f32::consts::FRAC_1_SQRT_2; // ≈ 0.7071 + // (L row, R row); each entry is the gain applied to that source channel. + let (l, r): (Vec, Vec) = match src_channels { + 3 => (vec![1.0, 0.0, C], vec![0.0, 1.0, C]), // FL FR FC + 4 => (vec![1.0, 0.0, C, 0.0], vec![0.0, 1.0, 0.0, C]), // quad: FL FR BL BR + 5 => (vec![1.0, 0.0, C, C, 0.0], vec![0.0, 1.0, C, 0.0, C]), // FL FR FC BL BR + // 5.1: FL FR FC LFE BL BR (LFE dropped) + 6 => (vec![1.0, 0.0, C, 0.0, C, 0.0], vec![0.0, 1.0, C, 0.0, 0.0, C]), + // 6.1: FL FR FC LFE BC SL SR (BC → both) + 7 => (vec![1.0, 0.0, C, 0.0, C, C, 0.0], vec![0.0, 1.0, C, 0.0, C, 0.0, C]), + // 7.1: FL FR FC LFE BL BR SL SR + 8 => ( + vec![1.0, 0.0, C, 0.0, C, 0.0, C, 0.0], + vec![0.0, 1.0, C, 0.0, 0.0, C, 0.0, C], + ), + _ => return None, + }; + let normalize = |row: Vec| -> Vec { + let sum: f32 = row.iter().map(|c| c.abs()).sum(); + if sum > 1.0 { + row.into_iter().map(|c| c / sum).collect() + } else { + row + } + }; + Some([normalize(l), normalize(r)]) +} + /// Windowed sinc interpolation for high-quality time stretching /// This is stateless and can handle arbitrary fractional positions #[inline] @@ -83,6 +123,16 @@ pub enum AudioStorage { decoded_frames: u64, total_frames: u64, }, + + /// Audio track of a video container, decoded on demand via FFmpeg + /// (`VideoAudioReader`). The source video file is `AudioFile::path`. Like + /// `Compressed`, playback is streamed through the disk reader and + /// `decoded_for_waveform` is filled progressively for the overview. + VideoAudio { + decoded_for_waveform: Vec, + decoded_frames: u64, + total_frames: u64, + }, } /// Audio file stored in the pool @@ -98,6 +148,10 @@ pub struct AudioFile { pub original_format: Option, /// Original compressed file bytes (preserved across save/load to avoid re-encoding) pub original_bytes: Option>, + /// When `Some`, this entry's bytes are packed in the project container (not on + /// disk at `path`); the disk reader opens them via the host's + /// `AudioBlobSourceFactory` using this media id. `None` ⇒ stream from `path`. + pub packed_media_id: Option, } impl AudioFile { @@ -112,6 +166,7 @@ impl AudioFile { frames, original_format: None, original_bytes: None, + packed_media_id: None, } } @@ -126,6 +181,7 @@ impl AudioFile { frames, original_format, original_bytes: None, + packed_media_id: None, } } @@ -158,6 +214,7 @@ impl AudioFile { frames: total_frames, original_format: Some("wav".to_string()), original_bytes: None, + packed_media_id: None, } } @@ -181,6 +238,32 @@ impl AudioFile { frames: total_frames, original_format, original_bytes: None, + packed_media_id: None, + } + } + + /// Create a placeholder AudioFile for a video's audio track. `path` is the + /// source video file; the audio is streamed on demand by the disk reader's + /// FFmpeg-backed `VideoAudioReader`. + pub fn from_video_audio( + path: PathBuf, + channels: u32, + sample_rate: u32, + total_frames: u64, + ) -> Self { + Self { + path, + storage: AudioStorage::VideoAudio { + decoded_for_waveform: Vec::new(), + decoded_frames: 0, + total_frames, + }, + channels, + sample_rate, + frames: total_frames, + original_format: None, + original_bytes: None, + packed_media_id: None, } } @@ -274,8 +357,8 @@ impl AudioFile { } written } - AudioStorage::Compressed { .. } => { - // Compressed files are read through the disk reader + AudioStorage::Compressed { .. } | AudioStorage::VideoAudio { .. } => { + // Streamed through the disk reader, not via read_samples(). 0 } } @@ -537,6 +620,15 @@ impl AudioClipPool { let dst_channels = engine_channels as usize; let output_frames = output.len() / dst_channels; + // Fold a multichannel source (5.1, 7.1, …) down to stereo with proper + // coefficients (centre + surrounds mixed in, LFE dropped) instead of just + // taking the front L/R pair. `None` ⇒ no downmix needed / unknown layout. + let downmix = if dst_channels == 2 && src_channels > 2 { + stereo_downmix_matrix(src_channels) + } else { + None + }; + let src_start_position = start_time_seconds * audio_file.sample_rate as f64; // Tell the disk reader where we're reading so it buffers the right region. @@ -582,6 +674,15 @@ impl AudioClipPool { sum += get_sample!(sf, src_ch); } sum / src_channels as f32 + } else if let Some(ref mat) = downmix { + // Surround → stereo with proper coefficients. + let mut s = 0.0f32; + for (src_ch, &c) in mat[dst_ch].iter().enumerate() { + if c != 0.0 { + s += c * get_sample!(sf, src_ch); + } + } + s } else { get_sample!(sf, dst_ch % src_channels) }; @@ -606,39 +707,45 @@ impl AudioClipPool { break; } - for dst_ch in 0..dst_channels { - let src_ch = if src_channels == dst_channels { - dst_ch - } else if src_channels == 1 { - 0 - } else if dst_channels == 1 { - usize::MAX // sentinel: average all channels below - } else { - dst_ch % src_channels - }; - - let sample = if src_ch == usize::MAX { - let mut sum = 0.0; - for ch in 0..src_channels { - let mut channel_samples = [0.0f32; KERNEL_SIZE]; - for (j, i) in (-(HALF_KERNEL as i32)..(HALF_KERNEL as i32)).enumerate() { - let idx = src_frame + i; - if idx >= 0 && (idx as usize) < audio_file.frames as usize { - channel_samples[j] = get_sample!(idx as usize, ch); - } - } - sum += windowed_sinc_interpolate(&channel_samples, frac); - } - sum / src_channels as f32 - } else { + // Sinc-interpolate a single source channel at the current position. + macro_rules! sinc_ch { + ($ch:expr) => {{ let mut channel_samples = [0.0f32; KERNEL_SIZE]; for (j, i) in (-(HALF_KERNEL as i32)..(HALF_KERNEL as i32)).enumerate() { let idx = src_frame + i; if idx >= 0 && (idx as usize) < audio_file.frames as usize { - channel_samples[j] = get_sample!(idx as usize, src_ch); + channel_samples[j] = get_sample!(idx as usize, $ch); } } windowed_sinc_interpolate(&channel_samples, frac) + }}; + } + + for dst_ch in 0..dst_channels { + let sample = if let Some(ref mat) = downmix { + // Surround → stereo: interpolate each contributing channel. + let mut s = 0.0f32; + for (ch, &c) in mat[dst_ch].iter().enumerate() { + if c != 0.0 { + s += c * sinc_ch!(ch); + } + } + s + } else if dst_channels == 1 { + let mut sum = 0.0; + for ch in 0..src_channels { + sum += sinc_ch!(ch); + } + sum / src_channels as f32 + } else { + let src_ch = if src_channels == dst_channels { + dst_ch + } else if src_channels == 1 { + 0 + } else { + dst_ch % src_channels + }; + sinc_ch!(src_ch) }; output[output_frame * dst_channels + dst_ch] += sample * gain; @@ -786,6 +893,25 @@ pub struct AudioPoolEntry { pub channels: u32, /// Embedded audio data (for files < 10MB) pub embedded_data: Option, + /// Stable media id (UUID string) for the SQLite `.beam` container. When set, + /// the audio bytes live in the container's `media` table keyed by this id + /// (packed storage). `None` for referenced entries (use `relative_path`) or + /// legacy ZIP-loaded entries. Populated by the file_io save/load layer. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media_id: Option, + /// Transient carrier for this entry's serialized waveform-pyramid blob (LBWF + /// bytes). Never serialized into project.json — the bytes live in the + /// container's `media` table (kind `Waveform`). Set by the file_io save layer + /// (in) and load layer (out); `None` everywhere else. + #[serde(skip)] + pub waveform_blob: Option>, + /// This entry is a video container's audio track (`relative_path` points at the + /// video file). It is always stored as a path reference (never packed/embedded + /// — the `VideoClip` already references the file) and reloaded by re-probing + /// the video via FFmpeg, so multichannel (5.1/7.1) audio survives the round-trip + /// (Symphonia reconstitution would otherwise collapse it). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub is_video_audio: bool, } impl AudioClipPool { @@ -800,6 +926,66 @@ impl AudioClipPool { let mut entries = Vec::new(); for (index, file) in self.files.iter().enumerate() { + // Skip placeholder pool slots: `load_from_serialized` resizes the pool to + // `max_index + 1` filled with empty `AudioFile::new(PathBuf::new(), …)` to + // cover index gaps (and creates one even when there are no entries at all). + // Such a slot has an empty path and no packed media — there's nothing to + // persist, and emitting it yields an entry whose empty `relative_path` + // resolves to the project directory itself (unreadable on the next save). + if file.path.as_os_str().is_empty() && file.packed_media_id.is_none() { + continue; + } + + // Video's audio track: reference the video file (it's also referenced + // by the VideoClip) and re-probe it via FFmpeg on load. Never pack or + // embed it as audio media — that both wastes space and loses the 5.1+ + // layout when Symphonia later decodes it. + if matches!(file.storage, AudioStorage::VideoAudio { .. }) { + let relative_path = pathdiff::diff_paths(&file.path, project_dir) + .map(|r| r.to_string_lossy().to_string()) + .or_else(|| Some(file.path.to_string_lossy().to_string())); + entries.push(AudioPoolEntry { + pool_index: index, + is_video_audio: true, + waveform_blob: None, + name: file + .path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| format!("file_{}", index)), + relative_path, + duration: file.duration_seconds(), + sample_rate: file.sample_rate, + channels: file.channels, + embedded_data: None, + media_id: None, + }); + continue; + } + + // Packed-in-container streaming entry: its bytes already live in the + // `.beam` media table (kept in place across re-saves). Emit just the + // media id — no path, no embedded bytes, nothing to decode. + if let Some(media_id) = &file.packed_media_id { + entries.push(AudioPoolEntry { + pool_index: index, + is_video_audio: false, + waveform_blob: None, + name: file + .path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| format!("file_{}", index)), + relative_path: None, + duration: file.duration_seconds(), + sample_rate: file.sample_rate, + channels: file.channels, + embedded_data: None, + media_id: Some(media_id.clone()), + }); + continue; + } + let file_path = &file.path; let file_path_str = file_path.to_string_lossy(); @@ -830,6 +1016,8 @@ impl AudioClipPool { let entry = AudioPoolEntry { pool_index: index, + is_video_audio: false, + waveform_blob: None, name: file_path .file_name() .map(|n| n.to_string_lossy().to_string()) @@ -839,6 +1027,7 @@ impl AudioClipPool { sample_rate: file.sample_rate, channels: file.channels, embedded_data, + media_id: None, }; entries.push(entry); @@ -962,22 +1151,86 @@ impl AudioClipPool { self.files.clear(); eprintln!("📊 [LOAD_SERIALIZED] Clear pool took {:.2}ms", clear_start.elapsed().as_secs_f64() * 1000.0); - // Find the maximum pool index to determine required size - let max_index = entries.iter() - .map(|e| e.pool_index) + // Size the pool to hold the highest pool_index (slots are addressed by index, + // so gaps are filled with placeholders). No entries → length 0, NOT 1: the old + // `max().unwrap_or(0) + 1` produced a spurious placeholder for an empty pool. + let pool_size = entries.iter() + .map(|e| e.pool_index + 1) .max() .unwrap_or(0); // Ensure we have space for all entries let resize_start = std::time::Instant::now(); - self.files.resize(max_index + 1, AudioFile::new(PathBuf::new(), Vec::new(), 2, 44100)); - eprintln!("📊 [LOAD_SERIALIZED] Resize pool to {} took {:.2}ms", max_index + 1, resize_start.elapsed().as_secs_f64() * 1000.0); + self.files.resize(pool_size, AudioFile::new(PathBuf::new(), Vec::new(), 2, 44100)); + eprintln!("📊 [LOAD_SERIALIZED] Resize pool to {} took {:.2}ms", pool_size, resize_start.elapsed().as_secs_f64() * 1000.0); for (i, entry) in entries.iter().enumerate() { let entry_start = std::time::Instant::now(); eprintln!("📊 [LOAD_SERIALIZED] Processing entry {}/{}: '{}'", i + 1, entries.len(), entry.name); - let success = if let Some(ref embedded) = entry.embedded_data { + let success = if entry.is_video_audio { + // Re-probe the video's audio track via FFmpeg → a streaming + // VideoAudio entry (keeps full 5.1/7.1; no decode-to-RAM). + match entry.relative_path.as_ref() { + Some(rel) => { + let full = if std::path::Path::new(rel).is_absolute() { + PathBuf::from(rel) + } else { + project_dir.join(rel) + }; + if full.exists() { + match crate::audio::disk_reader::VideoAudioReader::open(&full) { + Ok(reader) => { + let file = AudioFile::from_video_audio( + full, + reader.channels(), + reader.sample_rate(), + reader.total_frames(), + ); + if entry.pool_index < self.files.len() { + self.files[entry.pool_index] = file; + true + } else { + false + } + } + Err(e) => { + eprintln!("[AudioPool] Failed to reopen video audio {:?}: {}", full, e); + false + } + } + } else { + eprintln!("[AudioPool] Video file not found for audio: {:?}", full); + false + } + } + None => false, + } + } else if entry.media_id.is_some() && entry.embedded_data.is_none() { + // Packed-in-container streaming entry: build a Compressed placeholder + // backed by the host blob factory (opened at clip-activation time). + // No decode here — playback streams through the disk reader. + let media_id = entry.media_id.clone().unwrap(); + let ext = std::path::Path::new(&entry.name) + .extension() + .and_then(|e| e.to_str()) + .map(|s| s.to_lowercase()); + let total_frames = (entry.duration * entry.sample_rate as f64).ceil() as u64; + let mut file = AudioFile::from_compressed( + PathBuf::from(&entry.name), + entry.channels, + entry.sample_rate, + total_frames, + ext, + ); + file.packed_media_id = Some(media_id); + if entry.pool_index < self.files.len() { + self.files[entry.pool_index] = file; + true + } else { + false + } + } else if let Some(ref embedded) = entry.embedded_data { // Load from embedded data eprintln!("📊 [LOAD_SERIALIZED] Entry has embedded data (format: {})", embedded.format); match Self::load_from_embedded_into_pool(self, entry.pool_index, embedded.clone(), &entry.name) { diff --git a/daw-backend/src/audio/waveform_pyramid.rs b/daw-backend/src/audio/waveform_pyramid.rs new file mode 100644 index 0000000..6d58d42 --- /dev/null +++ b/daw-backend/src/audio/waveform_pyramid.rs @@ -0,0 +1,292 @@ +//! Streaming min/max waveform LOD pyramid. +//! +//! A waveform pyramid is a tree of zoom levels. **Index = tree depth:** +//! `levels[0]` is the **root** (a single texel — the min/max envelope of the +//! whole file, lowest resolution); each deeper level is `BRANCH`× finer, and +//! `levels.last()` is the **floor** (one texel per `floor_samples_per_texel` +//! source frames — the finest *persisted* level). A node's children live at +//! `index + 1`, so the residency invariant ("a node is cleared only after its +//! children") reads straight off the index. +//! +//! Below the floor (finer than the floor bucket) is *not* stored; the caller +//! re-decodes the source window on demand for true per-sample detail. +//! +//! The builder is **streaming**: samples are pushed once, in order, and only the +//! finest level is accumulated (~`total_frames / floor` texels); the coarser +//! levels are derived by repeated `BRANCH:1` min/max reduction in [`finish`]. +//! This yields the identical pyramid to an in-stream cascade (each parent = the +//! min/max of its children) without ever holding the full sample buffer. +//! +//! **Ragged edges are handled by reducing over available children:** a bucket +//! whose group is partial (1..BRANCH children, or `< floor` samples at the floor) +//! simply takes the min/max of what's there — no value padding. Padding to a +//! regular shape, if ever needed, is a GPU-texture/tile concern, not the data's. +//! +//! Each texel carries per-channel min/max for up to two channels +//! (`Lmin,Lmax,Rmin,Rmax`), matching the GPU waveform texture; mono mirrors the +//! left channel into the right. +//! +//! [`finish`]: WaveformPyramidBuilder::finish + +/// Reduction factor between adjacent pyramid levels. +pub const BRANCH: u32 = 4; + +/// Default finest-level resolution (source frames per floor texel). Trades +/// on-disk pyramid size against how soon zoom-in must re-decode the source. +pub const DEFAULT_FLOOR_SAMPLES_PER_TEXEL: u32 = 256; + +/// One waveform texel: per-channel min/max (stereo; mono duplicates left→right). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Texel { + pub l_min: f32, + pub l_max: f32, + pub r_min: f32, + pub r_max: f32, +} + +impl Texel { + const EMPTY: Texel = Texel { + l_min: f32::INFINITY, + l_max: f32::NEG_INFINITY, + r_min: f32::INFINITY, + r_max: f32::NEG_INFINITY, + }; + + #[inline] + fn include_sample(&mut self, l: f32, r: f32) { + self.l_min = self.l_min.min(l); + self.l_max = self.l_max.max(l); + self.r_min = self.r_min.min(r); + self.r_max = self.r_max.max(r); + } + + #[inline] + fn include_texel(&mut self, c: &Texel) { + self.l_min = self.l_min.min(c.l_min); + self.l_max = self.l_max.max(c.l_max); + self.r_min = self.r_min.min(c.r_min); + self.r_max = self.r_max.max(c.r_max); + } +} + +/// A built min/max LOD pyramid, **root-first**: `levels[0]` is the coarsest +/// (whole-file envelope), `levels.last()` is the finest persisted (floor). +#[derive(Clone, Debug)] +pub struct WaveformPyramid { + pub floor_samples_per_texel: u32, + pub branch: u32, + pub channels: u32, + pub total_frames: u64, + pub levels: Vec>, +} + +impl WaveformPyramid { + /// Coarsest level — a single texel (whole-file envelope), or empty if no + /// samples were pushed. + pub fn root(&self) -> &[Texel] { + self.levels.first().map_or(&[][..], |v| v) + } + + /// Finest persisted level (`floor_samples_per_texel` frames per texel). + pub fn floor(&self) -> &[Texel] { + self.levels.last().map_or(&[][..], |v| v) + } + + /// Number of levels (tree depth + 1). + pub fn depth(&self) -> usize { + self.levels.len() + } + + /// Serialize to a compact binary blob (for persisting in the `.beam` + /// container). Header carries `B`/branch/channels/total_frames + per-level + /// lengths, then root-first texel data (`f32` min/max). + pub fn to_bytes(&self) -> Vec { + let total_texels: usize = self.levels.iter().map(|l| l.len()).sum(); + let mut out = Vec::with_capacity(32 + self.levels.len() * 4 + total_texels * 16); + out.extend_from_slice(b"LBWF"); + out.extend_from_slice(&1u32.to_le_bytes()); // format version + out.extend_from_slice(&self.floor_samples_per_texel.to_le_bytes()); + out.extend_from_slice(&self.branch.to_le_bytes()); + out.extend_from_slice(&self.channels.to_le_bytes()); + out.extend_from_slice(&self.total_frames.to_le_bytes()); + out.extend_from_slice(&(self.levels.len() as u32).to_le_bytes()); + for level in &self.levels { + out.extend_from_slice(&(level.len() as u32).to_le_bytes()); + } + for level in &self.levels { + for t in level { + out.extend_from_slice(&t.l_min.to_le_bytes()); + out.extend_from_slice(&t.l_max.to_le_bytes()); + out.extend_from_slice(&t.r_min.to_le_bytes()); + out.extend_from_slice(&t.r_max.to_le_bytes()); + } + } + out + } + + /// Reconstruct from [`WaveformPyramid::to_bytes`]. + pub fn from_bytes(data: &[u8]) -> Result { + let mut r = ByteReader::new(data); + if r.take(4)? != b"LBWF" { + return Err("Not a waveform pyramid blob".to_string()); + } + let version = r.u32()?; + if version != 1 { + return Err(format!("Unsupported waveform pyramid version {}", version)); + } + let floor_samples_per_texel = r.u32()?; + let branch = r.u32()?; + let channels = r.u32()?; + let total_frames = r.u64()?; + let num_levels = r.u32()? as usize; + let mut level_lens = Vec::with_capacity(num_levels); + for _ in 0..num_levels { + level_lens.push(r.u32()? as usize); + } + let mut levels = Vec::with_capacity(num_levels); + for &len in &level_lens { + let mut level = Vec::with_capacity(len); + for _ in 0..len { + level.push(Texel { + l_min: r.f32()?, + l_max: r.f32()?, + r_min: r.f32()?, + r_max: r.f32()?, + }); + } + levels.push(level); + } + Ok(WaveformPyramid { + floor_samples_per_texel, + branch, + channels, + total_frames, + levels, + }) + } +} + +/// Minimal little-endian byte cursor for [`WaveformPyramid::from_bytes`]. +struct ByteReader<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> ByteReader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + fn take(&mut self, n: usize) -> Result<&'a [u8], String> { + let end = self.pos.checked_add(n).ok_or("overflow")?; + if end > self.data.len() { + return Err("Truncated waveform pyramid blob".to_string()); + } + let s = &self.data[self.pos..end]; + self.pos = end; + Ok(s) + } + fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap())) + } + fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes(self.take(8)?.try_into().unwrap())) + } + fn f32(&mut self) -> Result { + Ok(f32::from_le_bytes(self.take(4)?.try_into().unwrap())) + } +} + +/// Streaming builder for a [`WaveformPyramid`]. See the module docs. +pub struct WaveformPyramidBuilder { + floor: u32, + branch: u32, + channels: u32, + total_frames: u64, + floor_level: Vec, + acc: Texel, + acc_count: u32, +} + +impl WaveformPyramidBuilder { + pub fn new(channels: u32, floor_samples_per_texel: u32) -> Self { + Self { + floor: floor_samples_per_texel.max(1), + branch: BRANCH, + channels: channels.max(1), + total_frames: 0, + floor_level: Vec::new(), + acc: Texel::EMPTY, + acc_count: 0, + } + } + + /// Pre-reserve the floor `Vec` from an estimated total frame count (e.g. the + /// probe's `total_frames`), to avoid reallocations during streaming. Purely a + /// hint — the final size is set by the actual number of frames pushed. + pub fn reserve_for_frames(&mut self, estimated_frames: u64) { + let est_texels = (estimated_frames / self.floor as u64).saturating_add(1); + self.floor_level.reserve(est_texels.min(usize::MAX as u64) as usize); + } + + /// Push a block of interleaved samples (`channels` per frame). Partial + /// trailing frames (fewer than `channels`) are ignored. + pub fn push_interleaved(&mut self, samples: &[f32]) { + let ch = self.channels as usize; + for frame in samples.chunks_exact(ch) { + let l = frame[0]; + let r = if ch >= 2 { frame[1] } else { l }; + self.push_frame(l, r); + } + } + + #[inline] + fn push_frame(&mut self, l: f32, r: f32) { + self.total_frames += 1; + self.acc.include_sample(l, r); + self.acc_count += 1; + if self.acc_count >= self.floor { + self.floor_level.push(std::mem::replace(&mut self.acc, Texel::EMPTY)); + self.acc_count = 0; + } + } + + /// Flush the trailing partial bucket and reduce up to the root. + pub fn finish(mut self) -> WaveformPyramid { + if self.acc_count > 0 { + self.floor_level.push(self.acc); + } + + // Build finest-first by repeated BRANCH:1 reduction until one texel. + // The shape is fully determined by the floor texel count; the last group + // at each level may be ragged (1..BRANCH children) and reduces over what + // it has. + let mut levels = vec![std::mem::take(&mut self.floor_level)]; + let branch = self.branch as usize; + while levels.last().map_or(0, |l| l.len()) > 1 { + let prev = levels.last().unwrap(); + let mut next = Vec::with_capacity(prev.len().div_ceil(branch)); + for chunk in prev.chunks(branch) { + let mut t = Texel::EMPTY; + for c in chunk { + t.include_texel(c); + } + next.push(t); + } + levels.push(next); + } + // Output is root-first (convention B): levels[0] = root, last = floor. + levels.reverse(); + + WaveformPyramid { + floor_samples_per_texel: self.floor, + branch: self.branch, + channels: self.channels, + total_frames: self.total_frames, + levels, + } + } +} + +// Tests live in `daw-backend/tests/waveform_pyramid.rs` (integration tests) so +// they build the lib in normal mode, independent of the crate's pre-existing +// broken `#[cfg(test)]` unit tests (automation.rs). diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 5d481cb..cf29ccc 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -430,8 +430,6 @@ pub enum Query { /// Add a MIDI clip instance to a track synchronously (track_id, instance) - returns instance ID /// The clip must already exist in the MidiClipPool AddMidiClipInstanceSync(TrackId, crate::audio::midi::MidiClipInstance), - /// Add an audio file to the pool synchronously (path, data, channels, sample_rate) - returns pool index - AddAudioFileSync(String, Vec, u32, u32), /// Import an audio file synchronously (path) - returns pool index. /// Does the same work as Command::ImportAudio (mmap for PCM, streaming /// setup for compressed) but returns the real pool index in the response. @@ -440,12 +438,20 @@ pub enum Query { /// problem for very large files, switch to async import with event-based /// pool index reconciliation. ImportAudioSync(std::path::PathBuf), + /// Add the audio track of a video file as a streaming pool entry (FFmpeg, + /// decoded on demand — no extraction). Probes the audio track and returns + /// the pool index. Response: `AudioImportedSync`. + AddVideoAudioSync(std::path::PathBuf), /// Get raw audio samples from pool (pool_index) - returns (samples, sample_rate, channels) GetPoolAudioSamples(usize), /// Get a clone of the current project for serialization GetProject, /// Set the project (replaces current project state) SetProject(Box), + /// Install the host's packed-media byte-source factory (for streaming + /// container-packed audio on load). Sent before `SetProject` so bulk + /// activation can open packed sources. + SetBlobSourceFactory(std::sync::Arc), /// Duplicate a MIDI clip in the pool, returning the new clip's ID DuplicateMidiClipSync(MidiClipId), /// Get whether a track's graph is still the auto-generated default @@ -516,10 +522,10 @@ pub enum QueryResponse { AudioExported(Result<(), String>), /// MIDI clip instance added (returns instance ID) MidiClipInstanceAdded(Result), - /// Audio file added to pool (returns pool index) - AudioFileAddedSync(Result), /// Audio file imported to pool (returns pool index) AudioImportedSync(Result), + /// Packed-media byte-source factory installed + BlobSourceFactorySet(Result<(), String>), /// Raw audio samples from pool (samples, sample_rate, channels) PoolAudioSamples(Result<(Vec, u32, u32), String>), /// Project retrieved diff --git a/daw-backend/tests/compressed_source_stream.rs b/daw-backend/tests/compressed_source_stream.rs new file mode 100644 index 0000000..c9ab89d --- /dev/null +++ b/daw-backend/tests/compressed_source_stream.rs @@ -0,0 +1,89 @@ +//! Integration test for `CompressedReader::open_source` — decoding a streaming +//! audio source from an in-memory byte stream (the packed-in-container path) +//! rather than a filesystem path. Proves the `MediaByteSource` adapter feeds +//! Symphonia correctly (probe + decode + seekable byte length). + +use std::io::{Cursor, Read, Seek, SeekFrom}; + +use daw_backend::audio::disk_reader::{CompressedReader, MediaByteSource}; + +/// A `MediaByteSource` over an in-memory buffer (stands in for core's BlobReader). +struct VecSource(Cursor>); + +impl Read for VecSource { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.0.read(buf) + } +} +impl Seek for VecSource { + fn seek(&mut self, pos: SeekFrom) -> std::io::Result { + self.0.seek(pos) + } +} +impl MediaByteSource for VecSource { + fn byte_len(&self) -> u64 { + self.0.get_ref().len() as u64 + } +} + +/// Build a minimal PCM16 mono WAV byte buffer holding `frames` samples of a ramp. +fn make_wav(sample_rate: u32, frames: u32) -> Vec { + let channels: u16 = 1; + let bits: u16 = 16; + let block_align: u16 = channels * bits / 8; + let byte_rate: u32 = sample_rate * block_align as u32; + let data_len: u32 = frames * block_align as u32; + + let mut v = Vec::new(); + v.extend_from_slice(b"RIFF"); + v.extend_from_slice(&(36 + data_len).to_le_bytes()); + v.extend_from_slice(b"WAVE"); + v.extend_from_slice(b"fmt "); + v.extend_from_slice(&16u32.to_le_bytes()); + v.extend_from_slice(&1u16.to_le_bytes()); // PCM + v.extend_from_slice(&channels.to_le_bytes()); + v.extend_from_slice(&sample_rate.to_le_bytes()); + v.extend_from_slice(&byte_rate.to_le_bytes()); + v.extend_from_slice(&block_align.to_le_bytes()); + v.extend_from_slice(&bits.to_le_bytes()); + v.extend_from_slice(b"data"); + v.extend_from_slice(&data_len.to_le_bytes()); + for i in 0..frames { + // A ramp from -16000..16000 so values are recognizable. + let s = (((i % 1000) as i32 - 500) * 32) as i16; + v.extend_from_slice(&s.to_le_bytes()); + } + v +} + +#[test] +fn open_source_decodes_in_memory_wav() { + let sample_rate = 8000; + let frames = 4096; + let bytes = make_wav(sample_rate, frames); + + let src = Box::new(VecSource(Cursor::new(bytes))); + let mut reader = CompressedReader::open_source(src, Some("wav")) + .expect("open_source should probe the in-memory WAV"); + + assert_eq!(reader.sample_rate(), sample_rate); + assert_eq!(reader.channels(), 1); + + // Decode the whole stream and count emitted frames. + let mut buf = Vec::new(); + let mut decoded = 0usize; + loop { + let n = reader.decode_next(&mut buf).expect("decode_next"); + if n == 0 { + break; + } + decoded += n; + } + // Should recover (approximately) all frames — codec frame counts can round. + assert!( + (decoded as i64 - frames as i64).abs() < 64, + "decoded {} vs expected {}", + decoded, + frames + ); +} diff --git a/daw-backend/tests/video_audio_stream.rs b/daw-backend/tests/video_audio_stream.rs new file mode 100644 index 0000000..066ae62 --- /dev/null +++ b/daw-backend/tests/video_audio_stream.rs @@ -0,0 +1,253 @@ +//! Integration tests for `VideoAudioReader` (FFmpeg streaming audio source). +//! +//! These build the daw-backend lib in normal mode, so they're independent of +//! the crate's pre-existing broken `#[cfg(test)]` unit tests (automation.rs). +//! They synthesize a mono 32-bit-float WAV whose sample `i` has value `i/n`, so +//! a decoded sample's value identifies its frame index — letting us assert both +//! in-order decoding and **sample-accurate seeking** (the property video audio +//! needs to stay synced with other clips). + +use daw_backend::audio::disk_reader::{ + build_waveform_pyramid, CompressedReader, SourceKind, VideoAudioReader, +}; +use std::io::Write; +use std::path::Path; + +fn write_ramp_wav(path: &Path, n: u32, sample_rate: u32) { + let channels = 1u16; + let bytes_per_sample = 4u32; + let data_size = n * bytes_per_sample; + let mut buf: Vec = Vec::with_capacity(44 + data_size as usize); + buf.extend_from_slice(b"RIFF"); + buf.extend_from_slice(&(36 + data_size).to_le_bytes()); + buf.extend_from_slice(b"WAVE"); + buf.extend_from_slice(b"fmt "); + buf.extend_from_slice(&16u32.to_le_bytes()); + buf.extend_from_slice(&3u16.to_le_bytes()); // IEEE float + buf.extend_from_slice(&channels.to_le_bytes()); + buf.extend_from_slice(&sample_rate.to_le_bytes()); + buf.extend_from_slice(&(sample_rate * channels as u32 * bytes_per_sample).to_le_bytes()); + buf.extend_from_slice(&((channels as u32 * bytes_per_sample) as u16).to_le_bytes()); + buf.extend_from_slice(&32u16.to_le_bytes()); + buf.extend_from_slice(b"data"); + buf.extend_from_slice(&data_size.to_le_bytes()); + for i in 0..n { + buf.extend_from_slice(&((i as f32) / (n as f32)).to_le_bytes()); + } + let mut f = std::fs::File::create(path).unwrap(); + f.write_all(&buf).unwrap(); +} + +/// Stereo ramp: frame `i` has left = `i/n`, right = `0.5 - i/n` (distinct per +/// channel), interleaved `[L0,R0,L1,R1,…]`. Exercises the channels>1 path. +fn write_stereo_ramp_wav(path: &Path, n: u32, sample_rate: u32) { + let channels = 2u16; + let bytes_per_sample = 4u32; + let data_size = n * channels as u32 * bytes_per_sample; + let mut buf: Vec = Vec::with_capacity(44 + data_size as usize); + buf.extend_from_slice(b"RIFF"); + buf.extend_from_slice(&(36 + data_size).to_le_bytes()); + buf.extend_from_slice(b"WAVE"); + buf.extend_from_slice(b"fmt "); + buf.extend_from_slice(&16u32.to_le_bytes()); + buf.extend_from_slice(&3u16.to_le_bytes()); // IEEE float + buf.extend_from_slice(&channels.to_le_bytes()); + buf.extend_from_slice(&sample_rate.to_le_bytes()); + buf.extend_from_slice(&(sample_rate * channels as u32 * bytes_per_sample).to_le_bytes()); + buf.extend_from_slice(&((channels as u32 * bytes_per_sample) as u16).to_le_bytes()); + buf.extend_from_slice(&32u16.to_le_bytes()); + buf.extend_from_slice(b"data"); + buf.extend_from_slice(&data_size.to_le_bytes()); + for i in 0..n { + let l = i as f32 / n as f32; + let r = 0.5 - i as f32 / n as f32; + buf.extend_from_slice(&l.to_le_bytes()); + buf.extend_from_slice(&r.to_le_bytes()); + } + let mut f = std::fs::File::create(path).unwrap(); + f.write_all(&buf).unwrap(); +} + +fn temp_path(tag: &str) -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!("lb_videoaudio_test_{}_{}.wav", std::process::id(), tag)); + p +} + +#[test] +fn decodes_samples_in_order() { + let n = 4000u32; + let sr = 8000u32; + let path = temp_path("seq"); + write_ramp_wav(&path, n, sr); + + let mut reader = VideoAudioReader::open(&path).unwrap(); + assert_eq!(reader.channels(), 1); + assert_eq!(reader.sample_rate(), sr); + // Probe estimate (used by add_video_audio_sync) should be ~n frames. + let tf = reader.total_frames() as f64; + assert!( + (tf - n as f64).abs() < n as f64 * 0.1, + "total_frames {} not ~{}", + tf, n + ); + + let mut all = Vec::new(); + let mut buf = Vec::new(); + loop { + let frames = reader.decode_next(&mut buf).unwrap(); + if frames == 0 { + break; + } + all.extend_from_slice(&buf); + } + + // Allow a couple of priming/flush samples of slack at the very end. + assert!(all.len() + 4 >= n as usize, "decoded too few samples: {}", all.len()); + for (i, &v) in all.iter().enumerate().take(n as usize) { + let expected = i as f32 / n as f32; + assert!((v - expected).abs() < 1e-3, "sample {} = {}, expected {}", i, v, expected); + } + let _ = std::fs::remove_file(&path); +} + +/// CompressedReader (symphonia) must seek **sample-accurately** too, so compressed +/// audio stays frame-synced with video audio. Symphonia decodes WAV via the same +/// path; its coarse seek lands on packet boundaries, exercising the decode-discard. +#[test] +fn compressed_reader_seek_is_sample_accurate() { + let n = 4000u32; + let sr = 8000u32; + let path = temp_path("comp_seek"); + write_ramp_wav(&path, n, sr); + + let mut reader = CompressedReader::open(&path).unwrap(); + assert_eq!(reader.channels(), 1); + assert_eq!(reader.sample_rate(), sr); + + for &target in &[2000u64, 137, 3500, 0] { + let actual = reader.seek(target).unwrap(); + assert_eq!(actual, target, "seek should report the exact target"); + + let mut buf = Vec::new(); + let mut frames = 0; + for _ in 0..128 { + frames = reader.decode_next(&mut buf).unwrap(); + if frames > 0 { + break; + } + } + assert!(frames > 0, "no samples after seek to {}", target); + let expected = target as f32 / n as f32; + assert!( + (buf[0] - expected).abs() < 1e-3, + "compressed seek to {}: first sample = {}, expected {}", + target, buf[0], expected + ); + } + let _ = std::fs::remove_file(&path); +} + +/// The decode→pyramid bridge should produce an envelope matching the signal, +/// through both reader backends (symphonia + ffmpeg), with bounded memory. +#[test] +fn waveform_pyramid_from_decode_matches_signal() { + let n = 5000u32; + let sr = 8000u32; + let path = temp_path("pyr"); + write_ramp_wav(&path, n, sr); // ramp 0 .. (n-1)/n, all positive + + for kind in [SourceKind::CompressedAudio, SourceKind::VideoAudio] { + let p = build_waveform_pyramid(&path, kind, 256).unwrap(); + assert_eq!(p.channels, 1); + assert_eq!(p.root().len(), 1, "{:?}: root should be one texel", kind); + let root = p.root()[0]; + assert!(root.l_min.abs() < 1e-2, "{:?}: root min {} ~ 0", kind, root.l_min); + let expected_max = (n - 1) as f32 / n as f32; + assert!( + (root.l_max - expected_max).abs() < 1e-2, + "{:?}: root max {} ~ {}", kind, root.l_max, expected_max + ); + // Frame count is approximate across decoders (priming/resampler overhead); + // the envelope above is the real check. Just confirm it's about right. + assert!((p.total_frames as i64 - n as i64).abs() < 128, "{:?}: frames {}", kind, p.total_frames); + } + let _ = std::fs::remove_file(&path); +} + +#[test] +fn decodes_stereo_interleaved() { + let n = 2000u32; + let sr = 8000u32; + let path = temp_path("stereo"); + write_stereo_ramp_wav(&path, n, sr); + + let mut reader = VideoAudioReader::open(&path).unwrap(); + assert_eq!(reader.channels(), 2); + + let mut all = Vec::new(); + let mut buf = Vec::new(); + loop { + let frames = reader.decode_next(&mut buf).unwrap(); + if frames == 0 { + break; + } + // Each decode_next returns whole interleaved frames. + assert_eq!(buf.len() % 2, 0, "stereo decode returned a partial frame"); + all.extend_from_slice(&buf); + } + + // Interleaved L/R, ~n frames. + assert!(all.len() + 8 >= (n * 2) as usize, "decoded too few samples: {}", all.len()); + for i in 0..n as usize { + let l = all[2 * i]; + let r = all[2 * i + 1]; + assert!((l - i as f32 / n as f32).abs() < 1e-3, "L[{}]={} expected {}", i, l, i as f32 / n as f32); + assert!( + (r - (0.5 - i as f32 / n as f32)).abs() < 1e-3, + "R[{}]={} expected {}", i, r, 0.5 - i as f32 / n as f32 + ); + } + let _ = std::fs::remove_file(&path); +} + +#[test] +fn seek_is_sample_accurate() { + let n = 4000u32; + let sr = 8000u32; + let path = temp_path("seek"); + write_ramp_wav(&path, n, sr); + + let mut reader = VideoAudioReader::open(&path).unwrap(); + + for &target in &[2000u64, 137, 3500, 0] { + let actual = reader.seek(target).unwrap(); + assert_eq!(actual, target); + + // Pull the first non-empty decode after the seek. + let mut buf = Vec::new(); + let mut frames = 0; + for _ in 0..64 { + frames = reader.decode_next(&mut buf).unwrap(); + if frames > 0 { + break; + } + } + assert!(frames > 0, "no samples after seek to {}", target); + + let expected = target as f32 / n as f32; + assert!( + (buf[0] - expected).abs() < 1e-3, + "after seek to {}: first sample = {}, expected {}", + target, + buf[0], + expected + ); + // And the next few advance in order. + for k in 0..frames.min(8) { + let exp = (target as usize + k) as f32 / n as f32; + assert!((buf[k] - exp).abs() < 1e-3, "seek {}+{}: {} vs {}", target, k, buf[k], exp); + } + } + let _ = std::fs::remove_file(&path); +} diff --git a/daw-backend/tests/waveform_pyramid.rs b/daw-backend/tests/waveform_pyramid.rs new file mode 100644 index 0000000..fe67ef9 --- /dev/null +++ b/daw-backend/tests/waveform_pyramid.rs @@ -0,0 +1,135 @@ +//! Integration tests for the streaming waveform LOD pyramid builder. +//! +//! Convention B: `levels[0]` is the root (coarsest), `levels.last()` the floor +//! (finest). Tests use the `.root()` / `.floor()` accessors so they don't depend +//! on the raw index ordering. + +use daw_backend::audio::waveform_pyramid::{Texel, WaveformPyramid, WaveformPyramidBuilder}; + +fn build_mono(samples: &[f32], floor: u32) -> WaveformPyramid { + let mut b = WaveformPyramidBuilder::new(1, floor); + b.push_interleaved(samples); + b.finish() +} + +#[test] +fn floor_level_min_max_per_bucket() { + // 8 samples, floor 4 → two floor texels covering [0..4) and [4..8). + let s: Vec = (0..8).map(|i| i as f32).collect(); + let p = build_mono(&s, 4); + assert_eq!(p.floor().len(), 2); + assert_eq!(p.floor()[0], Texel { l_min: 0.0, l_max: 3.0, r_min: 0.0, r_max: 3.0 }); + assert_eq!(p.floor()[1], Texel { l_min: 4.0, l_max: 7.0, r_min: 4.0, r_max: 7.0 }); + // Root reduces the two floor texels into the envelope [0..8). + assert_eq!(p.root().len(), 1); + assert_eq!(p.root()[0], Texel { l_min: 0.0, l_max: 7.0, r_min: 0.0, r_max: 7.0 }); +} + +#[test] +fn partial_trailing_bucket_is_flushed() { + // 6 samples, floor 4 → texels [0..4) and a ragged [4..6). + let s: Vec = (0..6).map(|i| i as f32).collect(); + let p = build_mono(&s, 4); + assert_eq!(p.floor().len(), 2); + assert_eq!(p.floor()[1], Texel { l_min: 4.0, l_max: 5.0, r_min: 4.0, r_max: 5.0 }); + assert_eq!(p.total_frames, 6); +} + +#[test] +fn multi_level_envelope_matches_global_min_max() { + let s: Vec = (0..1000).map(|i| ((i as f32) * 0.01).sin()).collect(); + let g_min = s.iter().cloned().fold(f32::INFINITY, f32::min); + let g_max = s.iter().cloned().fold(f32::NEG_INFINITY, f32::max); + let p = build_mono(&s, 16); + assert_eq!(p.root().len(), 1); + assert!((p.root()[0].l_min - g_min).abs() < 1e-6); + assert!((p.root()[0].l_max - g_max).abs() < 1e-6); + // Every level's overall min/max equals the global (extremes are lossless). + for level in &p.levels { + let lmin = level.iter().map(|t| t.l_min).fold(f32::INFINITY, f32::min); + let lmax = level.iter().map(|t| t.l_max).fold(f32::NEG_INFINITY, f32::max); + assert!((lmin - g_min).abs() < 1e-6); + assert!((lmax - g_max).abs() < 1e-6); + } +} + +#[test] +fn levels_are_root_first_and_get_finer() { + let s: Vec = (0..1000).map(|i| i as f32).collect(); + let p = build_mono(&s, 16); + // Root first, floor last; strictly finer (more texels) as depth increases. + assert_eq!(p.root().len(), 1); + assert!(p.depth() >= 3); + for w in p.levels.windows(2) { + assert!(w[1].len() >= w[0].len(), "deeper level should be finer"); + } + // Floor has ceil(1000/16) = 63 texels. + assert_eq!(p.floor().len(), 63); +} + +#[test] +fn stereo_channels_tracked_separately() { + // L ramps up, R ramps down; interleaved. + let n = 64; + let mut s = Vec::new(); + for i in 0..n { + s.push(i as f32); // L + s.push(-(i as f32)); // R + } + let mut b = WaveformPyramidBuilder::new(2, 16); + b.push_interleaved(&s); + let p = b.finish(); + assert_eq!(p.root().len(), 1); + assert_eq!(p.root()[0].l_min, 0.0); + assert_eq!(p.root()[0].l_max, (n - 1) as f32); + assert_eq!(p.root()[0].r_min, -((n - 1) as f32)); + assert_eq!(p.root()[0].r_max, 0.0); +} + +#[test] +fn pyramid_size_is_bounded() { + let n = 100_000usize; + let s: Vec = (0..n).map(|i| (i % 7) as f32).collect(); + let floor = 256u32; + let p = build_mono(&s, floor); + let total: usize = p.levels.iter().map(|l| l.len()).sum(); + let floor_texels = (n as u32).div_ceil(floor) as usize; + // Geometric bound: < floor_texels * branch/(branch-1) + small per-level slack. + let bound = floor_texels * 4 / 3 + p.depth() + 2; + assert!(total <= bound, "pyramid too big: {} > {}", total, bound); +} + +#[test] +fn bytes_round_trip() { + let s: Vec = (0..3333).map(|i| ((i as f32) * 0.013).sin()).collect(); + let p = build_mono(&s, 64); + let bytes = p.to_bytes(); + let q = WaveformPyramid::from_bytes(&bytes).unwrap(); + assert_eq!(p.floor_samples_per_texel, q.floor_samples_per_texel); + assert_eq!(p.branch, q.branch); + assert_eq!(p.channels, q.channels); + assert_eq!(p.total_frames, q.total_frames); + assert_eq!(p.levels, q.levels); + // Truncated/garbage input is rejected, not panicking. + assert!(WaveformPyramid::from_bytes(&bytes[..bytes.len() - 4]).is_err()); + assert!(WaveformPyramid::from_bytes(b"nope").is_err()); +} + +#[test] +fn pushing_in_arbitrary_chunks_matches() { + // The streaming builder must be agnostic to how samples are chunked. + let s: Vec = (0..5000).map(|i| ((i * 13) % 97) as f32 - 48.0).collect(); + let whole = build_mono(&s, 32); + + let mut b = WaveformPyramidBuilder::new(1, 32); + b.reserve_for_frames(5000); + for chunk in s.chunks(37) { + b.push_interleaved(chunk); + } + let chunked = b.finish(); + + assert_eq!(whole.depth(), chunked.depth()); + for (a, c) in whole.levels.iter().zip(chunked.levels.iter()) { + assert_eq!(a, c); + } +} diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index f2aa015..4505926 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -2160,6 +2160,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.16.2" @@ -2925,6 +2937,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", +] [[package]] name = "hashbrown" @@ -2946,6 +2961,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heapless" version = "0.8.0" @@ -3415,6 +3439,17 @@ dependencies = [ "redox_syscall 0.5.18", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libxdo" version = "0.6.0" @@ -3455,6 +3490,7 @@ dependencies = [ "objc2-foundation 0.3.2", "pathdiff", "rstar", + "rusqlite", "serde", "serde_json", "tiny-skia", @@ -3469,7 +3505,7 @@ dependencies = [ [[package]] name = "lightningbeam-editor" -version = "1.0.3-alpha" +version = "1.0.4-alpha" dependencies = [ "beamdsp", "bytemuck", @@ -5478,6 +5514,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad8388ea1a9e0ea807e442e8263a699e7edcb320ecbcd21b4fa8ff859acce3ba" +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "1.1.0" diff --git a/lightningbeam-ui/lightningbeam-core/Cargo.toml b/lightningbeam-ui/lightningbeam-core/Cargo.toml index 7dea28c..99a68c9 100644 --- a/lightningbeam-ui/lightningbeam-core/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-core/Cargo.toml @@ -36,6 +36,9 @@ zip = "0.6" chrono = "0.4" base64 = "0.21" pathdiff = "0.2" +# .beam container: SQLite database file. `bundled` compiles SQLite from source +# (no system dependency); `blob` enables incremental blob I/O for streaming. +rusqlite = { version = "0.31", features = ["bundled", "blob"] } # Audio encoding for embedded files flacenc = "0.4" # For FLAC encoding (lossless) diff --git a/lightningbeam-ui/lightningbeam-core/src/action.rs b/lightningbeam-ui/lightningbeam-core/src/action.rs index 0720ecc..0a278f5 100644 --- a/lightningbeam-ui/lightningbeam-core/src/action.rs +++ b/lightningbeam-ui/lightningbeam-core/src/action.rs @@ -71,6 +71,15 @@ pub trait Action: Send { /// Get a human-readable description of this action (for UI display) fn description(&self) -> String; + /// For raster actions that store dirty-rect diffs: the `(layer_id, time)` of the + /// keyframe whose full pixels must be resident before `execute`/`rollback` can + /// apply the diff. The editor faults the frame in (synchronously) before undo/redo + /// so a paged-out clean frame is restored to its container state first. Non-raster + /// actions (and full-buffer ones) return `None`. + fn raster_resident_hint(&self) -> Option<(Uuid, f64)> { + None + } + /// Execute backend operations after document changes /// /// Called AFTER execute() succeeds. If this returns an error, execute() @@ -290,6 +299,18 @@ impl ActionExecutor { self.undo_stack.last().map(|a| a.description()) } + /// `(layer_id, time)` of the raster keyframe the next undo needs resident, if any. + /// The editor faults it in before calling `undo()` so a paged-out clean frame is + /// restored to its container state, giving the diff a correct base to apply onto. + pub fn peek_undo_raster_hint(&self) -> Option<(Uuid, f64)> { + self.undo_stack.last().and_then(|a| a.raster_resident_hint()) + } + + /// `(layer_id, time)` of the raster keyframe the next redo needs resident, if any. + pub fn peek_redo_raster_hint(&self) -> Option<(Uuid, f64)> { + self.redo_stack.last().and_then(|a| a.raster_resident_hint()) + } + /// Get MIDI cache data from the last action on the undo stack (after redo). /// Returns the notes reflecting execute state. pub fn last_undo_midi_notes(&self) -> Option<(u32, &[(f64, u8, u8, f64)])> { diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs index b952cdb..a1f438b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs @@ -235,11 +235,22 @@ impl Action for AddClipInstanceAction { } } AudioClipType::Sampled { audio_pool_index } => { + // `trim_*` / `clip.duration` are in SECONDS (audio content time), + // while `timeline_*` and the backend's `duration` are in BEATS. let internal_start = self.clip_instance.trim_start; let internal_end = self.clip_instance.trim_end.unwrap_or(clip.duration); - let effective_duration = self.clip_instance.timeline_duration - .unwrap_or(internal_end - internal_start); let start_time = self.clip_instance.timeline_start; + // `effective_duration` is in BEATS. When `timeline_duration` is set + // it already is; otherwise the clip occupies its natural content + // length, so convert that seconds-span to beats at the clip's start + // (NOT `internal_end - internal_start`, which is seconds — that was + // the seconds-as-beats bug that made clips stop early off 60 BPM). + let effective_duration = self.clip_instance.timeline_duration.unwrap_or_else(|| { + let tempo_map = document.tempo_map(); + let content_secs = internal_end - internal_start; + tempo_map.inverse_transform(tempo_map.transform(start_time) + content_secs) + - start_time + }); let instance_id = controller.add_audio_clip( *backend_track_id, @@ -305,8 +316,11 @@ mod tests { let layer_id = layer.layer.id; document.root_mut().add_child(AnyLayer::Vector(layer)); - // Create a clip instance (using a fake clip_id since we're just testing the action) + // Register a vector clip so get_clip_duration succeeds let clip_id = Uuid::new_v4(); + let clip = crate::clip::VectorClip::with_id(clip_id, "Test Clip", 100.0, 100.0, 5.0); + document.vector_clips.insert(clip_id, clip); + let clip_instance = ClipInstance::new(clip_id); let instance_id = clip_instance.id; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_raster_keyframe.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_raster_keyframe.rs new file mode 100644 index 0000000..d05d6d4 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_raster_keyframe.rs @@ -0,0 +1,52 @@ +//! Add a blank raster keyframe at the playhead (the explicit "New Keyframe" command +//! for raster layers — mirrors `SetKeyframeAction` for vector, but inserts an empty +//! cel rather than copying the current graph). + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use uuid::Uuid; + +pub struct AddRasterKeyframeAction { + layer_id: Uuid, + time: f64, + width: u32, + height: u32, + /// Id of the keyframe created by the last `execute` (so `rollback` can remove + /// exactly that one). `None` if a keyframe already existed at `time` (no-op). + created_id: Option, +} + +impl AddRasterKeyframeAction { + pub fn new(layer_id: Uuid, time: f64, width: u32, height: u32) -> Self { + Self { layer_id, time, width, height, created_id: None } + } +} + +impl Action for AddRasterKeyframeAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let layer = document + .get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + let rl = match layer { + AnyLayer::Raster(rl) => rl, + _ => return Err("Not a raster layer".to_string()), + }; + // Inserts a blank cel only if one doesn't already exist at this time. + self.created_id = rl.insert_blank_keyframe_at(self.time, self.width, self.height); + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + if let Some(id) = self.created_id.take() { + if let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&self.layer_id) { + rl.remove_keyframe(id); + } + } + Ok(()) + } + + fn description(&self) -> String { + "New keyframe".to_string() + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs index bba8cdf..11abe5e 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs @@ -23,6 +23,9 @@ pub struct AddShapeAction { stroke_color: Option, fill_color: Option, is_closed: bool, + /// When set, the enclosed region is filled with this image asset (instead of a + /// solid colour). The renderer prioritises `image_fill` over colour/gradient. + image_fill: Option, description_text: String, /// Snapshot of the graph before insertion (for undo). graph_before: Option, @@ -46,15 +49,53 @@ impl AddShapeAction { stroke_color, fill_color, is_closed, + image_fill: None, description_text: "Add shape".to_string(), graph_before: None, } } + /// A borderless, axis-aligned rectangle filled with an image asset — the result + /// of importing/dropping an image onto a vector layer. + pub fn image_rect( + layer_id: Uuid, + time: f64, + x: f64, + y: f64, + w: f64, + h: f64, + asset_id: Uuid, + ) -> Self { + let mut path = BezPath::new(); + path.move_to((x, y)); + path.line_to((x + w, y)); + path.line_to((x + w, y + h)); + path.line_to((x, y + h)); + path.close_path(); + Self { + layer_id, + time, + path, + stroke_style: None, // invisible edges — just the image + stroke_color: None, + fill_color: None, + is_closed: true, + image_fill: Some(asset_id), + description_text: "Add image".to_string(), + graph_before: None, + } + } + pub fn with_description(mut self, desc: impl Into) -> Self { self.description_text = desc.into(); self } + + /// Fill the created region with an image asset (image takes render priority). + pub fn with_image_fill(mut self, asset_id: Uuid) -> Self { + self.image_fill = Some(asset_id); + self + } } impl Action for AddShapeAction { @@ -87,16 +128,25 @@ impl Action for AddShapeAction { DEFAULT_SNAP_EPSILON, ); - // Apply fill if this is a closed shape with fill - if self.is_closed { - if let Some(ref fill) = self.fill_color { - // Compute centroid of the path's bounding box and paint-bucket fill - let bbox = self.path.bounding_box(); - let centroid = kurbo::Point::new( - (bbox.x0 + bbox.x1) / 2.0, - (bbox.y0 + bbox.y1) / 2.0, - ); - graph.paint_bucket(centroid, fill.clone(), FillRule::NonZero, 0.0); + // Apply fill if this is a closed shape with a colour and/or image fill. + if self.is_closed && (self.fill_color.is_some() || self.image_fill.is_some()) { + // Compute centroid of the path's bounding box and paint-bucket fill. + let bbox = self.path.bounding_box(); + let centroid = kurbo::Point::new( + (bbox.x0 + bbox.x1) / 2.0, + (bbox.y0 + bbox.y1) / 2.0, + ); + // paint_bucket needs a colour; an image-only fill uses a placeholder + // that the image overrides (cleared below). + let color = self.fill_color.clone().unwrap_or_else(|| ShapeColor::rgba(255, 255, 255, 255)); + if let Some(fid) = graph.paint_bucket(centroid, color, FillRule::NonZero, 0.0) { + if let Some(asset_id) = self.image_fill { + let fill = graph.fill_mut(fid); + fill.image_fill = Some(asset_id); + if self.fill_color.is_none() { + fill.color = None; // image-only: don't double-paint a colour + } + } } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/clip_from_geometry.rs b/lightningbeam-ui/lightningbeam-core/src/actions/clip_from_geometry.rs new file mode 100644 index 0000000..bbd418e --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/clip_from_geometry.rs @@ -0,0 +1,126 @@ +//! Shared logic for the "Group" and "Convert to Movie Clip" actions: extract the +//! selected DCEL geometry from a vector layer's active keyframe into a new `VectorClip` +//! and drop a `ClipInstance` in its place (so it can then be motion-tweened). +//! +//! A *group* (`is_group = true`) is a static container; a *movie clip* (`is_group = +//! false`) has its own timeline. Both are tweenable via the clip instance's transform. +//! +//! Both the Select tool and the (cut-and-select) RegionSelect tool populate the same +//! `Selection` ID sets, so a single entry point — [`extract_geometry_to_clip`] — handles +//! Group and Convert-to-Movie-Clip from whatever is selected. + +use std::collections::HashSet; + +use crate::clip::{ClipInstance, VectorClip}; +use crate::document::Document; +use crate::layer::{AnyLayer, ShapeKeyframe, VectorLayer}; +use crate::object::Transform; +use crate::vector_graph::{EdgeId, FillId, VectorGraph}; +use uuid::Uuid; + +/// Build a clip holding `sub_graph` and place a `ClipInstance` (with `transform`) on the +/// layer. Shared by both extraction paths. Does **not** touch the source graph — the +/// caller is responsible for having removed the moved geometry first. +fn assemble_clip_from_graph( + document: &mut Document, + layer_id: Uuid, + time: f64, + sub_graph: VectorGraph, + transform: Transform, + clip_id: Uuid, + instance_id: Uuid, + is_group: bool, + clip_name: &str, +) { + let (doc_w, doc_h, doc_dur) = (document.width, document.height, document.duration.max(1.0)); + + // A vector layer whose single keyframe holds the extracted graph (in the source's + // coordinate space, so an identity/translation placement renders it in place). + let mut inner = VectorLayer::new("Layer 1"); + let mut kf = ShapeKeyframe::new(0.0); + kf.graph = sub_graph; + inner.keyframes.push(kf); + let mut clip = VectorClip::with_id(clip_id, clip_name, doc_w, doc_h, doc_dur); + clip.is_group = is_group; + clip.layers.add_root(AnyLayer::Vector(inner)); + document.add_vector_clip(clip); + + let mut instance = ClipInstance::with_id(instance_id, clip_id); + instance.transform = transform; + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + // Groups gate visibility by the active keyframe's clip_instance_ids; movie + // clips render unconditionally. + if is_group { + if let Some(kf) = vl.keyframe_at_mut(time) { + kf.clip_instance_ids.push(instance_id); + } + } + vl.clip_instances.push(instance); + } +} + +/// Extract the selected geometry into a new clip + place a `ClipInstance`. Returns the +/// pre-extraction graph snapshot for undo. `clip_id`/`instance_id` are caller-provided +/// so undo/redo is stable. The selection sets come straight from the editor selection +/// (`select_fill` already includes each fill's boundary edges); `extract_subgraph` +/// derives which of those edges are shared with non-selected shapes. +pub fn extract_geometry_to_clip( + document: &mut Document, + layer_id: Uuid, + time: f64, + fills: &HashSet, + edges: &HashSet, + clip_id: Uuid, + instance_id: Uuid, + is_group: bool, + clip_name: &str, +) -> Result { + if fills.is_empty() && edges.is_empty() { + return Err("No geometry selected".to_string()); + } + + // 1. Extract from the source graph (extract_subgraph removes the moved geometry). + let (graph_before, sub_graph) = { + let layer = document.get_layer_mut(&layer_id).ok_or("Layer not found")?; + let vl = match layer { + AnyLayer::Vector(vl) => vl, + _ => return Err("Not a vector layer".to_string()), + }; + let graph = vl.graph_at_time_mut(time).ok_or("No keyframe at time")?; + let before = graph.clone(); + // No explicit cut boundary — extract_subgraph derives shared-fill boundaries. + let (sub, _, _) = graph.extract_subgraph(edges, fills, &HashSet::new()); + (before, sub) + }; + + // 2 & 3. Build the clip + place an identity-transform instance (geometry stays put). + assemble_clip_from_graph( + document, layer_id, time, sub_graph, Transform::default(), + clip_id, instance_id, is_group, clip_name, + ); + + Ok(graph_before) +} + +/// Reverse `extract_geometry_to_clip`: remove the clip + instance and restore the graph. +pub fn undo_extract_geometry( + document: &mut Document, + layer_id: Uuid, + time: f64, + clip_id: Uuid, + instance_id: Uuid, + graph_before: &VectorGraph, +) { + document.vector_clips.remove(&clip_id); + document.rebuild_layer_to_clip_map(); + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) { + vl.clip_instances.retain(|ci| ci.id != instance_id); + if let Some(kf) = vl.keyframe_at_mut(time) { + kf.clip_instance_ids.retain(|id| *id != instance_id); + } + if let Some(graph) = vl.graph_at_time_mut(time) { + *graph = graph_before.clone(); + } + } +} + diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/convert_to_movie_clip.rs b/lightningbeam-ui/lightningbeam-core/src/actions/convert_to_movie_clip.rs index c9ea444..fe5133a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/convert_to_movie_clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/convert_to_movie_clip.rs @@ -1,55 +1,57 @@ -//! Convert to Movie Clip action — STUB: needs DCEL rewrite +//! Convert to Movie Clip — extract selected geometry into a movie-clip `VectorClip` +//! (its own timeline) + a `ClipInstance` that can be motion-tweened. + +use std::collections::HashSet; use crate::action::Action; -use crate::clip::ClipInstance; +use crate::actions::clip_from_geometry::{extract_geometry_to_clip, undo_extract_geometry}; use crate::document::Document; +use crate::vector_graph::{EdgeId, FillId, VectorGraph}; use uuid::Uuid; -/// Action that converts selected items to a Movie Clip -/// TODO: Rewrite for DCEL -#[allow(dead_code)] pub struct ConvertToMovieClipAction { layer_id: Uuid, time: f64, - shape_ids: Vec, - clip_instance_ids: Vec, + fills: Vec, + edges: Vec, + clip_id: Uuid, instance_id: Uuid, - created_clip_id: Option, - removed_clip_instances: Vec, + graph_before: Option, } impl ConvertToMovieClipAction { pub fn new( layer_id: Uuid, time: f64, - shape_ids: Vec, - clip_instance_ids: Vec, + fills: Vec, + edges: Vec, + clip_id: Uuid, instance_id: Uuid, ) -> Self { - Self { - layer_id, - time, - shape_ids, - clip_instance_ids, - instance_id, - created_clip_id: None, - removed_clip_instances: Vec::new(), - } + Self { layer_id, time, fills, edges, clip_id, instance_id, graph_before: None } } } impl Action for ConvertToMovieClipAction { - fn execute(&mut self, _document: &mut Document) -> Result<(), String> { - let _ = (&self.layer_id, self.time, &self.shape_ids, &self.clip_instance_ids, self.instance_id); + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let fills: HashSet = self.fills.iter().copied().collect(); + let edges: HashSet = self.edges.iter().copied().collect(); + let before = extract_geometry_to_clip( + document, self.layer_id, self.time, &fills, &edges, + self.clip_id, self.instance_id, false, "Movie Clip", + )?; + self.graph_before = Some(before); Ok(()) } - fn rollback(&mut self, _document: &mut Document) -> Result<(), String> { + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + if let Some(before) = &self.graph_before { + undo_extract_geometry(document, self.layer_id, self.time, self.clip_id, self.instance_id, before); + } Ok(()) } fn description(&self) -> String { - let count = self.shape_ids.len() + self.clip_instance_ids.len(); - format!("Convert {} object(s) to Movie Clip", count) + "Convert to Movie Clip".to_string() } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/delete_folder.rs b/lightningbeam-ui/lightningbeam-core/src/actions/delete_folder.rs index 83d647c..2681265 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/delete_folder.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/delete_folder.rs @@ -37,6 +37,9 @@ pub struct DeleteFolderAction { /// Asset IDs that were deleted (for DeleteRecursive strategy) deleted_asset_ids: Vec, + + /// Subfolder IDs that were reparented to the deleted folder's parent (for MoveToParent strategy) + moved_subfolder_ids: Vec, } impl DeleteFolderAction { @@ -55,6 +58,7 @@ impl DeleteFolderAction { removed_folders: Vec::new(), moved_asset_ids: Vec::new(), deleted_asset_ids: Vec::new(), + moved_subfolder_ids: Vec::new(), } } } @@ -130,6 +134,7 @@ impl Action for DeleteFolderAction { for subfolder_id in subfolder_ids { if let Some(subfolder) = tree.folders.get_mut(&subfolder_id) { subfolder.parent_id = parent_id; + self.moved_subfolder_ids.push(subfolder_id); } } } @@ -259,6 +264,13 @@ impl Action for DeleteFolderAction { tree.add_folder(folder.clone()); } + // Restore reparented subfolders back under the deleted folder + for subfolder_id in &self.moved_subfolder_ids { + if let Some(subfolder) = tree.folders.get_mut(subfolder_id) { + subfolder.parent_id = Some(self.folder_id); + } + } + match self.strategy { DeleteStrategy::MoveToParent => { // Restore folder_id for moved assets @@ -312,6 +324,7 @@ impl Action for DeleteFolderAction { self.removed_folders.clear(); self.moved_asset_ids.clear(); self.deleted_asset_ids.clear(); + self.moved_subfolder_ids.clear(); Ok(()) } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/group_shapes.rs b/lightningbeam-ui/lightningbeam-core/src/actions/group_shapes.rs index ab32889..7043f84 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/group_shapes.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/group_shapes.rs @@ -1,56 +1,58 @@ -//! Group action — STUB: needs DCEL rewrite +//! Group action — extract selected geometry into a group `VectorClip` + a `ClipInstance`. + +use std::collections::HashSet; use crate::action::Action; -use crate::clip::ClipInstance; +use crate::actions::clip_from_geometry::{extract_geometry_to_clip, undo_extract_geometry}; use crate::document::Document; +use crate::vector_graph::{EdgeId, FillId, VectorGraph}; use uuid::Uuid; -/// Action that groups selected shapes and/or clip instances into a VectorClip -/// TODO: Rewrite for DCEL (group DCEL faces/edges into a sub-clip) -#[allow(dead_code)] +/// Groups the selected DCEL geometry (fills/edges) of a vector layer's active keyframe +/// into a new group clip, placing a clip instance in its place (which can be tweened). pub struct GroupAction { layer_id: Uuid, time: f64, - shape_ids: Vec, - clip_instance_ids: Vec, + fills: Vec, + edges: Vec, + clip_id: Uuid, instance_id: Uuid, - created_clip_id: Option, - removed_clip_instances: Vec, + graph_before: Option, } impl GroupAction { pub fn new( layer_id: Uuid, time: f64, - shape_ids: Vec, - clip_instance_ids: Vec, + fills: Vec, + edges: Vec, + clip_id: Uuid, instance_id: Uuid, ) -> Self { - Self { - layer_id, - time, - shape_ids, - clip_instance_ids, - instance_id, - created_clip_id: None, - removed_clip_instances: Vec::new(), - } + Self { layer_id, time, fills, edges, clip_id, instance_id, graph_before: None } } } impl Action for GroupAction { - fn execute(&mut self, _document: &mut Document) -> Result<(), String> { - let _ = (&self.layer_id, self.time, &self.shape_ids, &self.clip_instance_ids, self.instance_id); - // TODO: Implement DCEL-aware grouping + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let fills: HashSet = self.fills.iter().copied().collect(); + let edges: HashSet = self.edges.iter().copied().collect(); + let before = extract_geometry_to_clip( + document, self.layer_id, self.time, &fills, &edges, + self.clip_id, self.instance_id, true, "Group", + )?; + self.graph_before = Some(before); Ok(()) } - fn rollback(&mut self, _document: &mut Document) -> Result<(), String> { + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + if let Some(before) = &self.graph_before { + undo_extract_geometry(document, self.layer_id, self.time, self.clip_id, self.instance_id, before); + } Ok(()) } fn description(&self) -> String { - let count = self.shape_ids.len() + self.clip_instance_ids.len(); - format!("Group {} objects", count) + "Group".to_string() } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index ad49ecf..ed209cf 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -29,15 +29,20 @@ pub mod update_midi_events; pub mod loop_clip_instances; pub mod remove_clip_instances; pub mod set_keyframe; +pub mod set_tween; pub mod group_shapes; pub mod convert_to_movie_clip; pub mod region_split; pub mod toggle_group_expansion; pub mod group_layers; +pub mod clip_from_geometry; +pub mod raster_diff; pub mod raster_stroke; pub mod raster_fill; +pub mod add_raster_keyframe; pub mod move_layer; pub mod set_fill_paint; +pub mod set_image_fill; pub use add_clip_instance::AddClipInstanceAction; pub use add_effect::AddEffectAction; @@ -63,6 +68,7 @@ pub use update_midi_events::UpdateMidiEventsAction; pub use loop_clip_instances::LoopClipInstancesAction; pub use remove_clip_instances::RemoveClipInstancesAction; pub use set_keyframe::SetKeyframeAction; +pub use set_tween::SetTweenAction; pub use group_shapes::GroupAction; pub use convert_to_movie_clip::ConvertToMovieClipAction; pub use region_split::RegionSplitAction; @@ -70,7 +76,9 @@ pub use toggle_group_expansion::ToggleGroupExpansionAction; pub use group_layers::GroupLayersAction; pub use raster_stroke::RasterStrokeAction; pub use raster_fill::RasterFillAction; +pub use add_raster_keyframe::AddRasterKeyframeAction; pub use move_layer::MoveLayerAction; pub use set_fill_paint::SetFillPaintAction; +pub use set_image_fill::SetImageFillAction; pub use change_bpm::ChangeBpmAction; pub use change_fps::ChangeFpsAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/raster_diff.rs b/lightningbeam-ui/lightningbeam-core/src/actions/raster_diff.rs new file mode 100644 index 0000000..f1abb7e --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/raster_diff.rs @@ -0,0 +1,232 @@ +//! Dirty-rect diff for raster undo/redo. +//! +//! Brush strokes and fills used to store the *entire* before/after RGBA frame in the +//! undo stack (~8 MB each at 1080p → up to ~1.6 GB at the 100-action cap). A +//! [`RasterDiff`] instead stores only the changed bounding box's pixels before and +//! after, which for a typical brush dab is a few tens of KB. +//! +//! Applying a diff overwrites just the bbox of the keyframe's `raw_pixels`, so the +//! buffer **must be resident** (full length `w*h*4`) when `apply_*` runs. The editor +//! guarantees this by faulting the target frame in before undo/redo (a clean evicted +//! frame's container bytes equal its current logical state, so the restored base is +//! correct). If the base is somehow not resident we skip rather than corrupt. + +/// Normalize a buffer to full length `n`; an empty/short buffer becomes transparent. +fn normalize(buf: &[u8], n: usize) -> std::borrow::Cow<[u8]> { + if buf.len() == n { + std::borrow::Cow::Borrowed(buf) + } else { + std::borrow::Cow::Owned(vec![0u8; n]) + } +} + +/// A minimal before/after record of the region a raster edit changed. +#[derive(Clone, Debug)] +pub struct RasterDiff { + full_width: u32, + full_height: u32, + /// Changed bounding box `(x, y, w, h)`; `None` when before == after (no-op). + bbox: Option<(u32, u32, u32, u32)>, + /// bbox-sized RGBA (`w*h*4`) of the region before the edit. + before_region: Vec, + /// bbox-sized RGBA (`w*h*4`) of the region after the edit. + after_region: Vec, + /// The pre-edit buffer was blank (empty/unallocated) — i.e. this was the first + /// edit on a fresh keyframe. Lets `apply_after` build from a transparent base + /// (the commit/redo path often starts with empty `raw_pixels`) and `apply_before` + /// restore to blank, instead of requiring a resident base. + before_blank: bool, +} + +impl RasterDiff { + /// Build a diff from full before/after buffers. `after` is expected to be the + /// resident post-edit buffer (`width*height*4`); `before` may be empty (a blank + /// keyframe's first stroke), treated as fully transparent. + pub fn compute(before: &[u8], after: &[u8], width: u32, height: u32) -> Self { + let n = width as usize * height as usize * 4; + let before_blank = before.len() != n; + // Normalize both sides to full length; empty/short ⇒ transparent. + let before_full = normalize(before, n); + let after_full = normalize(after, n); + + // Find the tight bbox of differing pixels (compare 4-byte texels). + let (w, h) = (width as usize, height as usize); + let (mut min_x, mut min_y, mut max_x, mut max_y) = (usize::MAX, usize::MAX, 0usize, 0usize); + let mut any = false; + for y in 0..h { + let row = y * w * 4; + for x in 0..w { + let i = row + x * 4; + if before_full[i..i + 4] != after_full[i..i + 4] { + any = true; + if x < min_x { min_x = x; } + if x > max_x { max_x = x; } + if y < min_y { min_y = y; } + if y > max_y { max_y = y; } + } + } + } + + if !any { + return Self { full_width: width, full_height: height, bbox: None, + before_region: Vec::new(), after_region: Vec::new(), before_blank }; + } + + let bw = max_x - min_x + 1; + let bh = max_y - min_y + 1; + let crop = |full: &[u8]| -> Vec { + let mut out = Vec::with_capacity(bw * bh * 4); + for row in 0..bh { + let src = ((min_y + row) * w + min_x) * 4; + out.extend_from_slice(&full[src..src + bw * 4]); + } + out + }; + + Self { + full_width: width, + full_height: height, + bbox: Some((min_x as u32, min_y as u32, bw as u32, bh as u32)), + before_region: crop(&before_full), + after_region: crop(&after_full), + before_blank, + } + } + + /// Approximate retained size in bytes (the two cropped regions). + pub fn byte_size(&self) -> usize { + self.before_region.len() + self.after_region.len() + } + + /// Restore the pre-edit pixels into `raw` (undo / first-execute rollback). + pub fn apply_before(&self, raw: &mut Vec) { + if self.bbox.is_none() { + return; // no change + } + if self.before_blank { + // The frame was blank before this edit (it was the first stroke); undoing + // it returns to blank regardless of the current buffer. + raw.clear(); + return; + } + self.stamp_resident(&self.before_region, raw); + } + + /// Apply the post-edit pixels into `raw` (commit / redo). + pub fn apply_after(&self, raw: &mut Vec) { + if self.bbox.is_none() { + return; // no change + } + if self.before_blank { + // Base was blank: build a full transparent buffer then stamp the bbox. The + // commit/redo path frequently starts from empty `raw_pixels` here. + let n = self.full_width as usize * self.full_height as usize * 4; + raw.clear(); + raw.resize(n, 0); + } + self.stamp_resident(&self.after_region, raw); + } + + /// Stamp a bbox-sized region into `raw`, which must already be full-size. If it + /// isn't (a non-blank base that the editor failed to fault in), skip rather than + /// resize-and-corrupt — the frame will re-page to its container state. + fn stamp_resident(&self, region: &[u8], raw: &mut [u8]) { + let n = self.full_width as usize * self.full_height as usize * 4; + let (x, y, bw, bh) = match self.bbox { + Some(b) => b, + None => return, + }; + if raw.len() != n { + eprintln!( + "⚠️ [RASTER_DIFF] base not resident ({} != {}); skipping undo/redo apply", + raw.len(), n + ); + return; + } + let (x, y, bw, bh) = (x as usize, y as usize, bw as usize, bh as usize); + let fw = self.full_width as usize; + for row in 0..bh { + let dst = ((y + row) * fw + x) * 4; + let src = row * bw * 4; + raw[dst..dst + bw * 4].copy_from_slice(®ion[src..src + bw * 4]); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn solid(w: u32, h: u32, px: [u8; 4]) -> Vec { + px.iter().copied().cycle().take((w * h * 4) as usize).collect() + } + + #[test] + fn roundtrip_reproduces_buffers_exactly() { + let (w, h) = (8, 6); + let before = solid(w, h, [10, 20, 30, 255]); + let mut after = before.clone(); + // Change a 2x2 region at (3,2). + for (dy, dx) in [(0, 0), (0, 1), (1, 0), (1, 1)] { + let i = (((2 + dy) * w + (3 + dx)) * 4) as usize; + after[i..i + 4].copy_from_slice(&[200, 100, 50, 255]); + } + let diff = RasterDiff::compute(&before, &after, w, h); + assert_eq!(diff.bbox, Some((3, 2, 2, 2))); + + let mut buf = after.clone(); + diff.apply_before(&mut buf); + assert_eq!(buf, before, "undo must reproduce the pre-edit buffer exactly"); + diff.apply_after(&mut buf); + assert_eq!(buf, after, "redo must reproduce the post-edit buffer exactly"); + } + + #[test] + fn blank_before_first_stroke() { + let (w, h) = (4, 4); + let n = (w * h * 4) as usize; + let before: Vec = Vec::new(); // blank keyframe + let mut after = vec![0u8; n]; + let i = ((1 * w + 1) * 4) as usize; + after[i..i + 4].copy_from_slice(&[255, 0, 0, 255]); + let diff = RasterDiff::compute(&before, &after, w, h); + assert_eq!(diff.bbox, Some((1, 1, 1, 1))); + + // First execute / redo from EMPTY raw_pixels (the real commit path): builds + // the full buffer from transparent + the stroke. + let mut buf: Vec = Vec::new(); + diff.apply_after(&mut buf); + assert_eq!(buf, after, "commit/redo must build the frame from a blank base"); + + // Undo the first stroke → back to blank (empty). + diff.apply_before(&mut buf); + assert!(buf.is_empty(), "undoing the first stroke restores the blank keyframe"); + + // Redo again from the now-empty buffer. + diff.apply_after(&mut buf); + assert_eq!(buf, after); + } + + #[test] + fn no_change_is_noop() { + let (w, h) = (4, 4); + let buf = solid(w, h, [1, 2, 3, 4]); + let diff = RasterDiff::compute(&buf, &buf, w, h); + assert_eq!(diff.bbox, None); + assert_eq!(diff.byte_size(), 0); + let mut b = buf.clone(); + diff.apply_before(&mut b); + assert_eq!(b, buf); + } + + #[test] + fn not_resident_base_is_skipped_not_corrupted() { + let (w, h) = (4, 4); + let before = solid(w, h, [9, 9, 9, 255]); + let after = solid(w, h, [1, 2, 3, 255]); + let diff = RasterDiff::compute(&before, &after, w, h); + let mut empty: Vec = Vec::new(); + diff.apply_before(&mut empty); // base not resident + assert!(empty.is_empty(), "must not resize/corrupt a non-resident base"); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs b/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs index 4e2fc41..92c0dd0 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/raster_fill.rs @@ -1,6 +1,7 @@ //! Raster flood-fill action — records and undoes a paint bucket fill on a RasterLayer. use crate::action::Action; +use crate::actions::raster_diff::RasterDiff; use crate::document::Document; use crate::layer::AnyLayer; use uuid::Uuid; @@ -8,11 +9,13 @@ use uuid::Uuid; pub struct RasterFillAction { layer_id: Uuid, time: f64, - buffer_before: Vec, - buffer_after: Vec, width: u32, height: u32, name: String, + diff: RasterDiff, + /// Full post-fill buffer, kept only for the first `execute` (commit); see + /// `RasterStrokeAction::full_after`. + full_after: Option>, } impl RasterFillAction { @@ -24,7 +27,9 @@ impl RasterFillAction { width: u32, height: u32, ) -> Self { - Self { layer_id, time, buffer_before, buffer_after, width, height, name: "Flood fill".to_string() } + let diff = RasterDiff::compute(&buffer_before, &buffer_after, width, height); + Self { layer_id, time, width, height, name: "Flood fill".to_string(), + diff, full_after: Some(buffer_after) } } pub fn with_description(mut self, name: &str) -> Self { @@ -41,9 +46,17 @@ impl Action for RasterFillAction { AnyLayer::Raster(rl) => rl, _ => return Err("Not a raster layer".to_string()), }; - let kf = raster.ensure_keyframe_at(self.time, self.width, self.height); - kf.raw_pixels = self.buffer_after.clone(); + let _ = (self.width, self.height); + let kf = raster + .keyframe_at_mut(self.time) + .ok_or_else(|| format!("No raster keyframe at/before t={}", self.time))?; + if let Some(full) = self.full_after.take() { + kf.raw_pixels = full; + } else { + self.diff.apply_after(&mut kf.raw_pixels); + } kf.texture_dirty = true; + kf.dirty = true; Ok(()) } @@ -54,13 +67,21 @@ impl Action for RasterFillAction { AnyLayer::Raster(rl) => rl, _ => return Err("Not a raster layer".to_string()), }; - let kf = raster.ensure_keyframe_at(self.time, self.width, self.height); - kf.raw_pixels = self.buffer_before.clone(); + let _ = (self.width, self.height); + let kf = raster + .keyframe_at_mut(self.time) + .ok_or_else(|| format!("No raster keyframe at/before t={}", self.time))?; + self.diff.apply_before(&mut kf.raw_pixels); kf.texture_dirty = true; + kf.dirty = true; Ok(()) } fn description(&self) -> String { self.name.clone() } + + fn raster_resident_hint(&self) -> Option<(Uuid, f64)> { + Some((self.layer_id, self.time)) + } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs b/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs index 3c63808..06517b9 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/raster_stroke.rs @@ -9,6 +9,7 @@ //! `rollback` → swap in `buffer_before` use crate::action::Action; +use crate::actions::raster_diff::RasterDiff; use crate::document::Document; use crate::layer::AnyLayer; use uuid::Uuid; @@ -16,16 +17,20 @@ use uuid::Uuid; /// Action that records a single brush stroke for undo/redo. /// /// The stroke must already be painted into the document's `raw_pixels` before -/// this action is executed for the first time. +/// this action is executed for the first time. Only the changed bounding box is +/// retained (see [`RasterDiff`]) rather than two full frame buffers. pub struct RasterStrokeAction { layer_id: Uuid, time: f64, - /// Raw RGBA pixels *before* the stroke (for rollback / undo) - buffer_before: Vec, - /// Raw RGBA pixels *after* the stroke (for execute / redo) - buffer_after: Vec, width: u32, height: u32, + diff: RasterDiff, + /// The full post-stroke buffer, kept ONLY for the first `execute` (the commit), + /// which establishes `raw_pixels` exactly like the old code did — robust no matter + /// what state the working buffer is in (empty new keyframe, GPU-canvas readback, + /// etc.). Taken (dropped) on first execute, so the action sitting in the undo stack + /// retains only the small `diff`; redo then replays via the diff. + full_after: Option>, } impl RasterStrokeAction { @@ -33,6 +38,8 @@ impl RasterStrokeAction { /// /// * `buffer_before` – raw RGBA pixels captured just before the stroke began. /// * `buffer_after` – raw RGBA pixels captured just after the stroke finished. + /// + /// The full buffers are diffed down to the changed bbox here and then dropped. pub fn new( layer_id: Uuid, time: f64, @@ -41,28 +48,41 @@ impl RasterStrokeAction { width: u32, height: u32, ) -> Self { - Self { layer_id, time, buffer_before, buffer_after, width, height } + let diff = RasterDiff::compute(&buffer_before, &buffer_after, width, height); + Self { layer_id, time, width, height, diff, full_after: Some(buffer_after) } } } impl Action for RasterStrokeAction { fn execute(&mut self, document: &mut Document) -> Result<(), String> { let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?; - kf.raw_pixels = self.buffer_after.clone(); + if let Some(full) = self.full_after.take() { + // First execute (commit): assign the full buffer outright. + kf.raw_pixels = full; + } else { + // Redo: replay via the diff onto the (resident) base. + self.diff.apply_after(&mut kf.raw_pixels); + } kf.texture_dirty = true; + kf.dirty = true; Ok(()) } fn rollback(&mut self, document: &mut Document) -> Result<(), String> { let kf = get_keyframe_mut(document, &self.layer_id, self.time, self.width, self.height)?; - kf.raw_pixels = self.buffer_before.clone(); + self.diff.apply_before(&mut kf.raw_pixels); kf.texture_dirty = true; + kf.dirty = true; Ok(()) } fn description(&self) -> String { "Paint stroke".to_string() } + + fn raster_resident_hint(&self) -> Option<(Uuid, f64)> { + Some((self.layer_id, self.time)) + } } fn get_keyframe_mut<'a>( @@ -79,5 +99,10 @@ fn get_keyframe_mut<'a>( AnyLayer::Raster(rl) => rl, _ => return Err("Not a raster layer".to_string()), }; - Ok(raster.ensure_keyframe_at(time, width, height)) + let _ = (width, height); + // Edit the ACTIVE keyframe (at-or-before `time`); never create one — keyframes + // are made explicitly via "New Keyframe". + raster + .keyframe_at_mut(time) + .ok_or_else(|| format!("No raster keyframe at/before t={time}")) } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_image_fill.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_image_fill.rs new file mode 100644 index 0000000..9cf685c --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_image_fill.rs @@ -0,0 +1,67 @@ +//! Action that sets or clears the image fill on one or more VectorGraph fills. +//! +//! `image_fill` is an asset id the renderer maps onto the fill's bounding box; it +//! takes priority over colour/gradient. Setting `None` clears it (the colour/gradient +//! underneath shows again). + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use crate::vector_graph::FillId; +use uuid::Uuid; + +pub struct SetImageFillAction { + layer_id: Uuid, + time: f64, + fill_ids: Vec, + /// `Some(asset_id)` to set, `None` to clear. + new_image: Option, + /// Per-fill previous `image_fill`, for undo. + old: Vec<(FillId, Option)>, +} + +impl SetImageFillAction { + pub fn new(layer_id: Uuid, time: f64, fill_ids: Vec, image: Option) -> Self { + Self { layer_id, time, fill_ids, new_image: image, old: Vec::new() } + } + + fn get_graph_mut<'a>( + document: &'a mut Document, + layer_id: &Uuid, + time: f64, + ) -> Result<&'a mut crate::vector_graph::VectorGraph, String> { + let layer = document + .get_layer_mut(layer_id) + .ok_or_else(|| format!("Layer {} not found", layer_id))?; + match layer { + AnyLayer::Vector(vl) => vl + .graph_at_time_mut(time) + .ok_or_else(|| format!("No keyframe at time {}", time)), + _ => Err("Not a vector layer".to_string()), + } + } +} + +impl Action for SetImageFillAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let graph = Self::get_graph_mut(document, &self.layer_id, self.time)?; + self.old.clear(); + for &fid in &self.fill_ids { + self.old.push((fid, graph.fill(fid).image_fill)); + graph.fill_mut(fid).image_fill = self.new_image; + } + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let graph = Self::get_graph_mut(document, &self.layer_id, self.time)?; + for &(fid, old) in &self.old { + graph.fill_mut(fid).image_fill = old; + } + Ok(()) + } + + fn description(&self) -> String { + if self.new_image.is_some() { "Set image fill" } else { "Clear image fill" }.to_string() + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_keyframe.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_keyframe.rs index b2bb8dc..8f10b6f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/set_keyframe.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_keyframe.rs @@ -8,6 +8,7 @@ use crate::action::Action; use crate::animation::{AnimationCurve, AnimationTarget, Keyframe, TransformProperty}; use crate::document::Document; use crate::layer::{AnyLayer, ShapeKeyframe}; +use crate::object::Transform; use uuid::Uuid; /// Undo info for a clip animation curve @@ -54,11 +55,17 @@ const TRANSFORM_PROPERTIES: &[TransformProperty] = &[ TransformProperty::Opacity, ]; -fn transform_default(prop: &TransformProperty) -> f64 { +/// The clip instance's own value for a property (its base transform / opacity). +fn transform_prop_value(t: &Transform, opacity: f64, prop: &TransformProperty) -> f64 { match prop { - TransformProperty::ScaleX | TransformProperty::ScaleY => 1.0, - TransformProperty::Opacity => 1.0, - _ => 0.0, + TransformProperty::X => t.x, + TransformProperty::Y => t.y, + TransformProperty::Rotation => t.rotation, + TransformProperty::ScaleX => t.scale_x, + TransformProperty::ScaleY => t.scale_y, + TransformProperty::SkewX => t.skew_x, + TransformProperty::SkewY => t.skew_y, + TransformProperty::Opacity => opacity, } } @@ -67,6 +74,21 @@ impl Action for SetKeyframeAction { self.clip_undo_entries.clear(); self.shape_keyframe_created = false; + // Phase 1 (immutable): for each clip instance, gather its base transform and the + // start time of its visibility region, so a brand-new curve can be anchored there. + let mut clip_info: std::collections::HashMap = + std::collections::HashMap::new(); // id -> (base transform, opacity, start time) + if let Some(AnyLayer::Vector(vl)) = document.get_layer(&self.layer_id) { + for clip_id in &self.clip_instance_ids { + if let Some(ci) = vl.clip_instances.iter().find(|c| c.id == *clip_id) { + let start = vl + .group_visibility_start(clip_id, self.time) + .unwrap_or(ci.timeline_start); + clip_info.insert(*clip_id, (ci.transform.clone(), ci.opacity, start)); + } + } + } + let layer = document .get_layer_mut(&self.layer_id) .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; @@ -82,23 +104,37 @@ impl Action for SetKeyframeAction { // Add clip animation keyframes for clip_id in &self.clip_instance_ids { + let (base_transform, base_opacity, start) = clip_info + .get(clip_id) + .cloned() + .unwrap_or((Transform::new(), 1.0, 0.0)); for prop in TRANSFORM_PROPERTIES { let target = AnimationTarget::Object { id: *clip_id, property: *prop, }; - let default = transform_default(prop); - let value = vl.layer.animation_data.eval(&target, self.time, default); + // Fall back to the clip's OWN value (not a generic default) so a brand-new + // keyframe captures the actual on-stage position, not (0,0)/identity. + let base = transform_prop_value(&base_transform, base_opacity, prop); + let value = vl.layer.animation_data.eval(&target, self.time, base); let curve_created = vl.layer.animation_data.get_curve(&target).is_none(); if curve_created { vl.layer .animation_data - .set_curve(AnimationCurve::new(target.clone(), default)); + .set_curve(AnimationCurve::new(target.clone(), base)); } let curve = vl.layer.animation_data.get_curve_mut(&target).unwrap(); let old_keyframe = curve.get_keyframe_at(self.time, 0.001).cloned(); + + // When this is the first keyframe of the curve and the clip already existed + // before `time`, anchor a keyframe at its start with the original value. + // Otherwise a single keyframe would Hold-extrapolate backward and move the + // clip on every earlier frame too (the motion-tween first-keyframe bug). + if curve_created && start < self.time - 0.001 { + curve.set_keyframe(Keyframe::linear(start, base)); + } curve.set_keyframe(Keyframe::linear(self.time, value)); self.clip_undo_entries.push(ClipUndoEntry { @@ -145,3 +181,86 @@ impl Action for SetKeyframeAction { "New keyframe".to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::actions::TransformClipInstancesAction; + use crate::clip::ClipInstance; + use crate::layer::VectorLayer; + use std::collections::HashMap; + + fn x_curve_eval(document: &Document, layer_id: Uuid, instance_id: Uuid, time: f64) -> f64 { + let target = AnimationTarget::Object { id: instance_id, property: TransformProperty::X }; + match document.get_layer(&layer_id) { + Some(AnyLayer::Vector(vl)) => vl.layer.animation_data.eval(&target, time, f64::NAN), + _ => panic!("no layer"), + } + } + + #[test] + fn first_keyframe_then_move_does_not_disturb_earlier_frames() { + // Group created at frame 0 (clip at x=50), keyframe + move at frame 10 → x=200. + // Frame 0 must keep x=50 (the motion-tween first-keyframe bug: it used to become 200). + let mut document = Document::new("Test"); + let mut layer = VectorLayer::new("Layer"); + let clip_id = Uuid::new_v4(); + let instance_id = Uuid::new_v4(); + let mut instance = ClipInstance::with_id(instance_id, clip_id); + instance.transform = Transform::with_position(50.0, 50.0); + layer.clip_instances.push(instance); + // The group's visibility starts at a keyframe at time 0 containing the instance. + layer.ensure_keyframe_at(0.0).clip_instance_ids.push(instance_id); + let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer)); + + // Create a keyframe at frame 10. + SetKeyframeAction::new(layer_id, 10.0, vec![instance_id]) + .execute(&mut document) + .unwrap(); + + // The new curve must be anchored at the start (two keyframes, both at x=50 so far). + assert!((x_curve_eval(&document, layer_id, instance_id, 0.0) - 50.0).abs() < 1e-6); + assert!((x_curve_eval(&document, layer_id, instance_id, 10.0) - 50.0).abs() < 1e-6); + + // Move the clip at frame 10 to x=200. + let mut transforms = HashMap::new(); + transforms.insert( + instance_id, + (Transform::with_position(50.0, 50.0), Transform::with_position(200.0, 200.0)), + ); + TransformClipInstancesAction::new(layer_id, 10.0, transforms) + .execute(&mut document) + .unwrap(); + + // Frame 0 unchanged; frame 10 moved; midpoint tweens. + assert!((x_curve_eval(&document, layer_id, instance_id, 0.0) - 50.0).abs() < 1e-6, "frame 0 must stay 50"); + assert!((x_curve_eval(&document, layer_id, instance_id, 10.0) - 200.0).abs() < 1e-6, "frame 10 must be 200"); + assert!((x_curve_eval(&document, layer_id, instance_id, 5.0) - 125.0).abs() < 1e-6, "midpoint tweens"); + } + + #[test] + fn first_keyframe_at_clip_start_is_not_double_anchored() { + // When the keyframe is created at the clip's own start, there's nothing earlier to + // anchor — a single keyframe is correct. + let mut document = Document::new("Test"); + let mut layer = VectorLayer::new("Layer"); + let clip_id = Uuid::new_v4(); + let instance_id = Uuid::new_v4(); + let mut instance = ClipInstance::with_id(instance_id, clip_id); + instance.transform = Transform::with_position(10.0, 0.0); + layer.clip_instances.push(instance); + layer.ensure_keyframe_at(0.0).clip_instance_ids.push(instance_id); + let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer)); + + SetKeyframeAction::new(layer_id, 0.0, vec![instance_id]) + .execute(&mut document) + .unwrap(); + + let target = AnimationTarget::Object { id: instance_id, property: TransformProperty::X }; + if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) { + let curve = vl.layer.animation_data.get_curve(&target).unwrap(); + assert_eq!(curve.keyframes.len(), 1, "keyframe at clip start needs no anchor"); + assert!((curve.eval(0.0) - 10.0).abs() < 1e-6); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_tween.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_tween.rs new file mode 100644 index 0000000..51ce85a --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_tween.rs @@ -0,0 +1,57 @@ +//! Set the tween type on the keyframe at-or-before a time (e.g. "Add Shape Tween"). +//! +//! The keyframe's `tween_after` controls how the span between it and the next keyframe is +//! rendered: `None` holds, `Shape` morphs the geometry (when the two keyframes share +//! topology — otherwise rendering falls back to holding). + +use crate::action::Action; +use crate::document::Document; +use crate::layer::{AnyLayer, TweenType}; +use uuid::Uuid; + +pub struct SetTweenAction { + layer_id: Uuid, + time: f64, + new_tween: TweenType, + old_tween: Option, +} + +impl SetTweenAction { + pub fn new(layer_id: Uuid, time: f64, new_tween: TweenType) -> Self { + Self { layer_id, time, new_tween, old_tween: None } + } +} + +impl Action for SetTweenAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&self.layer_id) { + if let Some(kf) = vl.keyframe_at_mut(self.time) { + self.old_tween = Some(kf.tween_after); + kf.tween_after = self.new_tween; + } else { + return Err("No keyframe at-or-before this time".to_string()); + } + } else { + return Err("Not a vector layer".to_string()); + } + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + if let (Some(old), Some(AnyLayer::Vector(vl))) = + (self.old_tween, document.get_layer_mut(&self.layer_id)) + { + if let Some(kf) = vl.keyframe_at_mut(self.time) { + kf.tween_after = old; + } + } + Ok(()) + } + + fn description(&self) -> String { + match self.new_tween { + TweenType::Shape => "Add shape tween".to_string(), + TweenType::None => "Remove tween".to_string(), + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs index ee349b8..9595ba3 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs @@ -547,6 +547,8 @@ mod tests { // Create a clip ID (ClipInstance references clip by ID) let clip_id = uuid::Uuid::new_v4(); + let clip = crate::clip::VectorClip::with_id(clip_id, "Test Clip", 100.0, 100.0, 10.0); + document.vector_clips.insert(clip_id, clip); let mut vector_layer = VectorLayer::new("Layer 1"); @@ -607,6 +609,8 @@ mod tests { // Create a clip ID (ClipInstance references clip by ID) let clip_id = uuid::Uuid::new_v4(); + let clip = crate::clip::VectorClip::with_id(clip_id, "Test Clip", 100.0, 100.0, 10.0); + document.vector_clips.insert(clip_id, clip); let mut vector_layer = VectorLayer::new("Layer 1"); diff --git a/lightningbeam-ui/lightningbeam-core/src/animation.rs b/lightningbeam-ui/lightningbeam-core/src/animation.rs index 0a0a16b..ba8ad50 100644 --- a/lightningbeam-ui/lightningbeam-core/src/animation.rs +++ b/lightningbeam-ui/lightningbeam-core/src/animation.rs @@ -322,6 +322,20 @@ impl AnimationCurve { } } + /// True when `time` lies strictly between two keyframes — an in-between frame of a + /// tween (not on a keyframe, not in the pre/post-extrapolation tails). + pub fn is_tween_inbetween(&self, time: f64, tol: f64) -> bool { + if self.keyframes.len() < 2 { + return false; + } + let first = self.keyframes.first().unwrap().time; + let last = self.keyframes.last().unwrap().time; + if time <= first + tol || time >= last - tol { + return false; + } + !self.keyframes.iter().any(|kf| (kf.time - time).abs() <= tol) + } + /// Extrapolate before the first keyframe fn extrapolate_pre(&self, time: f64, first_kf: &Keyframe) -> f64 { match self.pre_extrapolation { @@ -516,6 +530,17 @@ impl AnimationData { self.curves.remove(target) } + /// True when the object (e.g. a clip instance) is mid motion-tween at `time` — any of + /// its curves has `time` strictly between two keyframes. Used to lock out editing on + /// in-between frames (editing there would silently insert a keyframe and disturb the tween). + pub fn is_object_tweened_at(&self, id: uuid::Uuid, time: f64) -> bool { + const TOL: f64 = 0.001; + self.curves.iter().any(|(target, curve)| { + matches!(target, AnimationTarget::Object { id: oid, .. } if *oid == id) + && curve.is_tween_inbetween(time, TOL) + }) + } + /// Evaluate a property at a given time pub fn eval(&self, target: &AnimationTarget, time: f64, default: f64) -> f64 { self.curves @@ -545,3 +570,51 @@ impl AnimationData { (t, opacity) } } + +#[cfg(test)] +mod tween_lock_tests { + use super::*; + + #[test] + fn curve_in_between_detection() { + let mut c = AnimationCurve::new( + AnimationTarget::Object { id: uuid::Uuid::nil(), property: TransformProperty::X }, + 0.0, + ); + c.set_keyframe(Keyframe::linear(0.0, 0.0)); + c.set_keyframe(Keyframe::linear(10.0, 100.0)); + + assert!(c.is_tween_inbetween(5.0, 0.001), "strictly between keyframes"); + assert!(!c.is_tween_inbetween(0.0, 0.001), "on a keyframe"); + assert!(!c.is_tween_inbetween(10.0, 0.001), "on a keyframe"); + assert!(!c.is_tween_inbetween(15.0, 0.001), "past the last keyframe (extrapolation tail)"); + } + + #[test] + fn single_keyframe_is_never_in_between() { + let mut c = AnimationCurve::new( + AnimationTarget::Object { id: uuid::Uuid::nil(), property: TransformProperty::X }, + 0.0, + ); + c.set_keyframe(Keyframe::linear(10.0, 100.0)); + assert!(!c.is_tween_inbetween(5.0, 0.001)); + assert!(!c.is_tween_inbetween(20.0, 0.001)); + } + + #[test] + fn object_tweened_when_any_curve_is_in_between() { + let id = uuid::Uuid::new_v4(); + let mut data = AnimationData::new(); + let mut cx = AnimationCurve::new( + AnimationTarget::Object { id, property: TransformProperty::X }, + 0.0, + ); + cx.set_keyframe(Keyframe::linear(0.0, 0.0)); + cx.set_keyframe(Keyframe::linear(10.0, 100.0)); + data.set_curve(cx); + + assert!(data.is_object_tweened_at(id, 5.0)); + assert!(!data.is_object_tweened_at(id, 0.0)); + assert!(!data.is_object_tweened_at(uuid::Uuid::new_v4(), 5.0), "different object"); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/beam_archive.rs b/lightningbeam-ui/lightningbeam-core/src/beam_archive.rs new file mode 100644 index 0000000..39ef088 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/beam_archive.rs @@ -0,0 +1,813 @@ +//! SQLite-backed `.beam` project container. +//! +//! The `.beam` format is a single SQLite database file. It replaces the older +//! ZIP-archive format. SQLite gives us, in one file: +//! +//! - **Streaming reads** — packed media is split into chunk rows and read on +//! demand through [`BlobReader`] (`Read + Seek`), so arbitrary-length audio / +//! video never has to be fully decoded into RAM on load. +//! - **In-place, crash-safe mutation** — raster frame write-back and re-save are +//! transactional `UPDATE`s rather than rewriting a whole archive. +//! - **Single-file UX** — behaves like a file on every platform. +//! +//! ## Media storage +//! +//! Each media item is one row in `media` plus, when *packed*, N rows in +//! `media_chunk`: +//! +//! - **Packed** (`MediaStorage::Packed`) — bytes live in the database, split +//! into [`CHUNK_SIZE`]-byte chunks. Chunking keeps each blob well under +//! SQLite's ~2 GB per-blob ceiling and bounds the working set of a streaming +//! reader to a single chunk. +//! - **Referenced** (`MediaStorage::Referenced`) — only an external path is +//! stored; the bytes stay on disk (useful for shared media on a network drive, +//! or media too large/volatile to pack). Callers open the path directly. +//! +//! `project.json` (the serialized `BeamProject`) is stored verbatim in the +//! single-row `project_json` table; only the container and media storage change +//! relative to the legacy format. + +use rusqlite::blob::Blob; +use rusqlite::{Connection, DatabaseName, OpenFlags, OptionalExtension}; +use std::io::{self, Read, Seek, SeekFrom}; +use std::path::Path; +use uuid::Uuid; + +/// Default packed-media chunk size: 4 MiB. +/// +/// Small enough to bound a streaming reader's per-chunk work and any +/// whole-chunk buffering, large enough to keep row counts modest (a 1 GB file +/// is 256 rows). Comfortably under SQLite's ~2 GB per-blob limit. +pub const CHUNK_SIZE: u64 = 4 * 1024 * 1024; + +/// Files at or above this size prompt the user to pick packed vs referenced +/// (and the choice is then persisted as the default). Matches SQLite's +/// practical large-blob threshold. +pub const LARGE_MEDIA_THRESHOLD: u64 = 2 * 1024 * 1024 * 1024; + +/// Kind of media stored in the `media` table. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MediaKind { + Audio = 0, + Video = 1, + Raster = 2, + ImageAsset = 3, + /// A precomputed waveform LOD pyramid blob for an audio item (keyed by the + /// same id as the audio it describes). See `daw_backend::audio::waveform_pyramid`. + Waveform = 4, + /// A pack of precomputed video thumbnails for a video clip (keyed by a + /// sentinel-derived id from the clip id). Opaque blob; format owned by the editor. + Thumbnail = 5, + /// A low-res PNG proxy of a raster keyframe (keyed by a sentinel-derived id from + /// the keyframe id). Decoded eagerly on load and shown while the full-res pixels + /// page in, so cold scrubs don't flash blank. See `raster_proxy_media_id`. + RasterProxy = 6, +} + +impl MediaKind { + fn from_i64(v: i64) -> Option { + match v { + 0 => Some(Self::Audio), + 1 => Some(Self::Video), + 2 => Some(Self::Raster), + 3 => Some(Self::ImageAsset), + 4 => Some(Self::Waveform), + 5 => Some(Self::Thumbnail), + 6 => Some(Self::RasterProxy), + _ => None, + } + } +} + +/// How a media item's bytes are stored. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MediaStorage { + /// Bytes are chunked into `media_chunk` rows inside the database. + Packed = 0, + /// Only an external path is stored; bytes live on disk. + Referenced = 1, +} + +impl MediaStorage { + fn from_i64(v: i64) -> Option { + match v { + 0 => Some(Self::Packed), + 1 => Some(Self::Referenced), + _ => None, + } + } +} + +/// Metadata row for a media item (no bytes). +#[derive(Debug, Clone)] +pub struct MediaInfo { + pub id: Uuid, + pub kind: MediaKind, + /// Original codec / container extension, e.g. `"flac"`, `"mp3"`, `"png"`. + pub codec: String, + pub storage: MediaStorage, + /// Set when `storage == Referenced`. + pub ext_path: Option, + /// Total byte length of the media payload (packed only; 0 for referenced). + pub total_len: u64, + // Kind-specific metadata (nullable; meaning depends on `kind`). + pub channels: Option, + pub sample_rate: Option, + pub width: Option, + pub height: Option, +} + +/// Optional kind-specific metadata supplied when writing a media item. +#[derive(Debug, Clone, Copy, Default)] +pub struct MediaMeta { + pub channels: Option, + pub sample_rate: Option, + pub width: Option, + pub height: Option, +} + +/// A `.beam` project container backed by a SQLite database. +pub struct BeamArchive { + conn: Connection, + chunk_size: u64, +} + +impl BeamArchive { + /// Schema version stored in `meta` under `"schema_version"`. + pub const SCHEMA_VERSION: i64 = 1; + + /// Create a new (empty) archive at `path`, replacing any existing file. + pub fn create(path: &Path) -> Result { + // Remove any existing file so we start from a clean schema. + if path.exists() { + std::fs::remove_file(path).map_err(|e| format!("Failed to replace {:?}: {}", path, e))?; + } + let conn = Connection::open(path).map_err(map_sql)?; + let mut archive = Self { conn, chunk_size: CHUNK_SIZE }; + archive.init_schema()?; + Ok(archive) + } + + /// Open an existing archive for read/write. + pub fn open(path: &Path) -> Result { + let conn = Connection::open(path).map_err(map_sql)?; + let archive = Self { conn, chunk_size: CHUNK_SIZE }; + archive.verify_schema()?; + Ok(archive) + } + + /// Quick check: does `path` look like a SQLite database (vs. a legacy ZIP)? + /// Reads the 16-byte SQLite header magic. Used to route between the SQLite + /// loader and the legacy-ZIP migration path. + pub fn is_sqlite(path: &Path) -> bool { + use std::io::Read as _; + let mut f = match std::fs::File::open(path) { + Ok(f) => f, + Err(_) => return false, + }; + let mut magic = [0u8; 16]; + if f.read_exact(&mut magic).is_err() { + return false; + } + &magic == b"SQLite format 3\0" + } + + fn init_schema(&mut self) -> Result<(), String> { + self.conn + .execute_batch( + "BEGIN; + CREATE TABLE media ( + id BLOB PRIMARY KEY, -- 16-byte Uuid + kind INTEGER NOT NULL, + codec TEXT NOT NULL, + storage INTEGER NOT NULL, + ext_path TEXT, + total_len INTEGER NOT NULL DEFAULT 0, + channels INTEGER, + sample_rate INTEGER, + width INTEGER, + height INTEGER + ); + CREATE TABLE media_chunk ( + id INTEGER PRIMARY KEY, -- rowid, for blob_open + media_id BLOB NOT NULL, + chunk_index INTEGER NOT NULL, + bytes BLOB NOT NULL, + UNIQUE (media_id, chunk_index) + ); + CREATE TABLE project_json ( + id INTEGER PRIMARY KEY CHECK (id = 0), + data TEXT NOT NULL + ); + CREATE TABLE meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + COMMIT;", + ) + .map_err(map_sql)?; + self.set_meta("schema_version", &Self::SCHEMA_VERSION.to_string())?; + Ok(()) + } + + fn verify_schema(&self) -> Result<(), String> { + let v: Option = self.get_meta("schema_version")?; + match v.as_deref().and_then(|s| s.parse::().ok()) { + Some(n) if n <= Self::SCHEMA_VERSION => Ok(()), + Some(n) => Err(format!( + "Unsupported .beam schema version {} (this build supports up to {})", + n, + Self::SCHEMA_VERSION + )), + None => Err("Not a valid .beam archive (missing schema_version)".to_string()), + } + } + + /// Begin a write transaction grouping multiple media/json writes into one + /// atomic, crash-safe commit. Used by saves so unchanged (large) media is + /// never rewritten — only dirty rows are touched, in place. + pub fn transaction(&mut self) -> Result, String> { + let tx = self.conn.transaction().map_err(map_sql)?; + Ok(BeamTxn { tx, chunk_size: self.chunk_size }) + } + + // -- meta key/value -------------------------------------------------- + + pub fn set_meta(&self, key: &str, value: &str) -> Result<(), String> { + set_meta_conn(&self.conn, key, value) + } + + pub fn get_meta(&self, key: &str) -> Result, String> { + self.conn + .query_row("SELECT value FROM meta WHERE key = ?1", [key], |r| r.get(0)) + .optional() + .map_err(map_sql) + } + + // -- project.json ---------------------------------------------------- + + /// Store the serialized `project.json` (single row). + pub fn set_project_json(&self, json: &str) -> Result<(), String> { + set_project_json_conn(&self.conn, json) + } + + /// Read the serialized `project.json`. + pub fn get_project_json(&self) -> Result { + self.conn + .query_row("SELECT data FROM project_json WHERE id = 0", [], |r| r.get(0)) + .optional() + .map_err(map_sql)? + .ok_or_else(|| "Archive has no project.json".to_string()) + } + + // -- media write ----------------------------------------------------- + + /// Write a media item whose bytes are packed (chunked) into the database. + /// Replaces any existing rows for `id`. + pub fn put_media_packed( + &mut self, + id: Uuid, + kind: MediaKind, + codec: &str, + bytes: &[u8], + meta: MediaMeta, + ) -> Result<(), String> { + let tx = self.conn.transaction().map_err(map_sql)?; + write_media_packed(&tx, self.chunk_size, id, kind, codec, bytes, meta)?; + tx.commit().map_err(map_sql)?; + Ok(()) + } + + /// Write a media item that references an external file by path (no bytes + /// stored). Replaces any existing rows for `id`. + pub fn put_media_referenced( + &mut self, + id: Uuid, + kind: MediaKind, + codec: &str, + ext_path: &str, + meta: MediaMeta, + ) -> Result<(), String> { + let tx = self.conn.transaction().map_err(map_sql)?; + write_media_referenced(&tx, id, kind, codec, ext_path, meta)?; + tx.commit().map_err(map_sql)?; + Ok(()) + } + + // -- media read ------------------------------------------------------ + + /// Look up a media item's metadata. + pub fn media_info(&self, id: Uuid) -> Result, String> { + let id_bytes = id.as_bytes().to_vec(); + self.conn + .query_row( + "SELECT kind, codec, storage, ext_path, total_len, channels, sample_rate, width, height + FROM media WHERE id = ?1", + [&id_bytes], + |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, String>(1)?, + r.get::<_, i64>(2)?, + r.get::<_, Option>(3)?, + r.get::<_, i64>(4)?, + r.get::<_, Option>(5)?, + r.get::<_, Option>(6)?, + r.get::<_, Option>(7)?, + r.get::<_, Option>(8)?, + )) + }, + ) + .optional() + .map_err(map_sql)? + .map(|(kind, codec, storage, ext_path, total_len, channels, sample_rate, width, height)| { + Ok(MediaInfo { + id, + kind: MediaKind::from_i64(kind) + .ok_or_else(|| format!("Unknown media kind {}", kind))?, + codec, + storage: MediaStorage::from_i64(storage) + .ok_or_else(|| format!("Unknown media storage {}", storage))?, + ext_path, + total_len: total_len.max(0) as u64, + channels, + sample_rate, + width, + height, + }) + }) + .transpose() + } + + /// List every media item of a given kind. + pub fn media_ids_of_kind(&self, kind: MediaKind) -> Result, String> { + let mut stmt = self + .conn + .prepare("SELECT id FROM media WHERE kind = ?1") + .map_err(map_sql)?; + let rows = stmt + .query_map([kind as i64], |r| r.get::<_, Vec>(0)) + .map_err(map_sql)?; + let mut out = Vec::new(); + for row in rows { + let bytes = row.map_err(map_sql)?; + out.push(uuid_from_bytes(&bytes)?); + } + Ok(out) + } + + /// Read an entire packed media item into memory. Convenience for small + /// media (raster frames, image assets); large media should stream via + /// [`BeamArchive::open_blob_reader`] instead. + pub fn read_media_full(&self, id: Uuid) -> Result, String> { + let info = self + .media_info(id)? + .ok_or_else(|| format!("Media {} not found", id))?; + if info.storage != MediaStorage::Packed { + return Err(format!("Media {} is referenced, not packed", id)); + } + let id_bytes = id.as_bytes().to_vec(); + let mut stmt = self + .conn + .prepare("SELECT bytes FROM media_chunk WHERE media_id = ?1 ORDER BY chunk_index") + .map_err(map_sql)?; + let rows = stmt + .query_map([&id_bytes], |r| r.get::<_, Vec>(0)) + .map_err(map_sql)?; + let mut out = Vec::with_capacity(info.total_len as usize); + for row in rows { + out.extend_from_slice(&row.map_err(map_sql)?); + } + Ok(out) + } + + /// Open a streaming reader over a packed media item. The reader owns its own + /// SQLite connection (read-only) so it can live on a separate thread (e.g. + /// the audio disk reader) independent of this archive handle. + pub fn open_blob_reader(&self, db_path: &Path, id: Uuid) -> Result { + let info = self + .media_info(id)? + .ok_or_else(|| format!("Media {} not found", id))?; + if info.storage != MediaStorage::Packed { + return Err(format!("Media {} is referenced, not packed", id)); + } + BlobReader::open(db_path, id, info.total_len, self.chunk_size) + } + + /// Override the chunk size (testing / tuning). Affects subsequent writes. + #[doc(hidden)] + pub fn set_chunk_size(&mut self, chunk_size: u64) { + assert!(chunk_size > 0); + self.chunk_size = chunk_size; + } +} + +/// Streaming reader (`Read + Seek`) over a packed media item's chunk rows. +/// +/// Owns a dedicated read-only SQLite connection so it is independent of the +/// writing [`BeamArchive`] handle and can be moved to another thread. Each +/// `read` opens a blob handle on the current chunk's row via `blob_open` (no +/// per-read query — chunk rowids are resolved once up front) and reads up to the +/// chunk boundary; callers that issue many tiny reads should wrap this in a +/// `BufReader`. +pub struct BlobReader { + conn: Connection, + chunk_rowids: Vec, + chunk_size: u64, + total_len: u64, + pos: u64, +} + +impl BlobReader { + fn open(db_path: &Path, id: Uuid, total_len: u64, chunk_size: u64) -> Result { + let conn = Connection::open_with_flags( + db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(map_sql)?; + let id_bytes = id.as_bytes().to_vec(); + let mut stmt = conn + .prepare("SELECT id FROM media_chunk WHERE media_id = ?1 ORDER BY chunk_index") + .map_err(map_sql)?; + let rows = stmt + .query_map([&id_bytes], |r| r.get::<_, i64>(0)) + .map_err(map_sql)?; + let mut chunk_rowids = Vec::new(); + for row in rows { + chunk_rowids.push(row.map_err(map_sql)?); + } + drop(stmt); + Ok(Self { conn, chunk_rowids, chunk_size, total_len, pos: 0 }) + } + + /// Total length of the media payload in bytes. + pub fn len(&self) -> u64 { + self.total_len + } + + pub fn is_empty(&self) -> bool { + self.total_len == 0 + } + + fn chunk_blob(&self, chunk_index: usize) -> io::Result> { + let rowid = *self + .chunk_rowids + .get(chunk_index) + .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "chunk index out of range"))?; + self.conn + .blob_open(DatabaseName::Main, "media_chunk", "bytes", rowid, true) + .map_err(|e| io::Error::new(io::ErrorKind::Other, e)) + } +} + +impl Read for BlobReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if self.pos >= self.total_len || buf.is_empty() { + return Ok(0); + } + let chunk_index = (self.pos / self.chunk_size) as usize; + let off_in_chunk = self.pos % self.chunk_size; + + // The chunk's length is derivable from total_len/chunk_size, so we don't + // depend on Blob::len(): every chunk but the last is exactly chunk_size. + let chunk_start = chunk_index as u64 * self.chunk_size; + let chunk_len = (self.total_len - chunk_start).min(self.chunk_size); + let avail_in_chunk = (chunk_len - off_in_chunk) as usize; + let avail_total = (self.total_len - self.pos) as usize; + let want = buf.len().min(avail_in_chunk).min(avail_total); + + // Scope the blob borrow (it borrows `self.conn`) so it ends before we + // mutate `self.pos`. + let n = { + let mut blob = self.chunk_blob(chunk_index)?; + blob.seek(SeekFrom::Start(off_in_chunk))?; + blob.read(&mut buf[..want])? + }; + self.pos += n as u64; + Ok(n) + } +} + +impl Seek for BlobReader { + fn seek(&mut self, pos: SeekFrom) -> io::Result { + let new_pos = match pos { + SeekFrom::Start(n) => n as i64, + SeekFrom::End(n) => self.total_len as i64 + n, + SeekFrom::Current(n) => self.pos as i64 + n, + }; + if new_pos < 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "seek before start of media", + )); + } + // Allow seeking to/past end (reads then return 0), matching File semantics. + self.pos = new_pos as u64; + Ok(self.pos) + } +} + +/// A write transaction over a [`BeamArchive`]. All writes are buffered until +/// [`BeamTxn::commit`]; dropping without committing rolls back. Lets a save +/// touch only the rows that changed, in place, without rewriting unchanged media. +pub struct BeamTxn<'a> { + tx: rusqlite::Transaction<'a>, + chunk_size: u64, +} + +impl BeamTxn<'_> { + pub fn put_media_packed( + &self, + id: Uuid, + kind: MediaKind, + codec: &str, + bytes: &[u8], + meta: MediaMeta, + ) -> Result<(), String> { + write_media_packed(&self.tx, self.chunk_size, id, kind, codec, bytes, meta) + } + + pub fn put_media_referenced( + &self, + id: Uuid, + kind: MediaKind, + codec: &str, + ext_path: &str, + meta: MediaMeta, + ) -> Result<(), String> { + write_media_referenced(&self.tx, id, kind, codec, ext_path, meta) + } + + /// Like [`BeamTxn::put_media_packed`] but streams the bytes from a file on + /// disk chunk-by-chunk, so an arbitrarily large file is never fully loaded + /// into memory. `total_len` is taken from the bytes actually read. + pub fn put_media_packed_from_path( + &self, + id: Uuid, + kind: MediaKind, + codec: &str, + path: &Path, + meta: MediaMeta, + ) -> Result<(), String> { + write_media_packed_from_path(&self.tx, self.chunk_size, id, kind, codec, path, meta) + } + + pub fn set_project_json(&self, json: &str) -> Result<(), String> { + set_project_json_conn(&self.tx, json) + } + + pub fn set_meta(&self, key: &str, value: &str) -> Result<(), String> { + set_meta_conn(&self.tx, key, value) + } + + /// Does a media row with this id already exist? + pub fn media_exists(&self, id: Uuid) -> Result { + let id_bytes = id.as_bytes().to_vec(); + let n: i64 = self + .tx + .query_row("SELECT COUNT(*) FROM media WHERE id = ?1", [&id_bytes], |r| r.get(0)) + .map_err(map_sql)?; + Ok(n > 0) + } + + /// Every media id currently in the archive. + pub fn all_media_ids(&self) -> Result, String> { + let mut stmt = self.tx.prepare("SELECT id FROM media").map_err(map_sql)?; + let rows = stmt.query_map([], |r| r.get::<_, Vec>(0)).map_err(map_sql)?; + let mut out = Vec::new(); + for row in rows { + out.push(uuid_from_bytes(&row.map_err(map_sql)?)?); + } + Ok(out) + } + + /// Delete a media row (and its chunks). + pub fn delete_media(&self, id: Uuid) -> Result<(), String> { + let id_bytes = id.as_bytes().to_vec(); + self.tx + .execute("DELETE FROM media_chunk WHERE media_id = ?1", [&id_bytes]) + .map_err(map_sql)?; + self.tx + .execute("DELETE FROM media WHERE id = ?1", [&id_bytes]) + .map_err(map_sql)?; + Ok(()) + } + + /// Delete every media row whose id is not in `keep` (orphan cleanup). + pub fn retain_media(&self, keep: &std::collections::HashSet) -> Result { + let mut removed = 0; + for id in self.all_media_ids()? { + if !keep.contains(&id) { + self.delete_media(id)?; + removed += 1; + } + } + Ok(removed) + } + + pub fn commit(self) -> Result<(), String> { + self.tx.commit().map_err(map_sql) + } +} + +// -- shared write helpers (used by both BeamArchive and BeamTxn) -------------- + +fn write_media_packed( + conn: &Connection, + chunk_size: u64, + id: Uuid, + kind: MediaKind, + codec: &str, + bytes: &[u8], + meta: MediaMeta, +) -> Result<(), String> { + let id_bytes = id.as_bytes().to_vec(); + conn.execute("DELETE FROM media WHERE id = ?1", [&id_bytes]).map_err(map_sql)?; + conn.execute("DELETE FROM media_chunk WHERE media_id = ?1", [&id_bytes]) + .map_err(map_sql)?; + conn.execute( + "INSERT INTO media + (id, kind, codec, storage, ext_path, total_len, channels, sample_rate, width, height) + VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?6, ?7, ?8, ?9)", + rusqlite::params![ + id_bytes, + kind as i64, + codec, + MediaStorage::Packed as i64, + bytes.len() as i64, + meta.channels, + meta.sample_rate, + meta.width, + meta.height, + ], + ) + .map_err(map_sql)?; + for (chunk_index, chunk) in bytes.chunks(chunk_size as usize).enumerate() { + conn.execute( + "INSERT INTO media_chunk (media_id, chunk_index, bytes) VALUES (?1, ?2, ?3)", + rusqlite::params![id_bytes, chunk_index as i64, chunk], + ) + .map_err(map_sql)?; + } + Ok(()) +} + +fn write_media_packed_from_path( + conn: &Connection, + chunk_size: u64, + id: Uuid, + kind: MediaKind, + codec: &str, + path: &Path, + meta: MediaMeta, +) -> Result<(), String> { + let id_bytes = id.as_bytes().to_vec(); + conn.execute("DELETE FROM media WHERE id = ?1", [&id_bytes]).map_err(map_sql)?; + conn.execute("DELETE FROM media_chunk WHERE media_id = ?1", [&id_bytes]) + .map_err(map_sql)?; + + let file = std::fs::File::open(path).map_err(|e| format!("Failed to open {:?}: {}", path, e))?; + let mut reader = std::io::BufReader::new(file); + let mut buf = vec![0u8; chunk_size as usize]; + let mut chunk_index: i64 = 0; + let mut total_len: u64 = 0; + + loop { + // Fill `buf` up to chunk_size, tolerating short reads. + let mut filled = 0usize; + while filled < buf.len() { + let n = reader + .read(&mut buf[filled..]) + .map_err(|e| format!("Failed to read {:?}: {}", path, e))?; + if n == 0 { + break; + } + filled += n; + } + if filled == 0 { + break; + } + conn.execute( + "INSERT INTO media_chunk (media_id, chunk_index, bytes) VALUES (?1, ?2, ?3)", + rusqlite::params![id_bytes, chunk_index, &buf[..filled]], + ) + .map_err(map_sql)?; + chunk_index += 1; + total_len += filled as u64; + if filled < buf.len() { + break; // reached EOF + } + } + + conn.execute( + "INSERT INTO media + (id, kind, codec, storage, ext_path, total_len, channels, sample_rate, width, height) + VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?6, ?7, ?8, ?9)", + rusqlite::params![ + id_bytes, + kind as i64, + codec, + MediaStorage::Packed as i64, + total_len as i64, + meta.channels, + meta.sample_rate, + meta.width, + meta.height, + ], + ) + .map_err(map_sql)?; + Ok(()) +} + +fn write_media_referenced( + conn: &Connection, + id: Uuid, + kind: MediaKind, + codec: &str, + ext_path: &str, + meta: MediaMeta, +) -> Result<(), String> { + let id_bytes = id.as_bytes().to_vec(); + conn.execute("DELETE FROM media WHERE id = ?1", [&id_bytes]).map_err(map_sql)?; + conn.execute("DELETE FROM media_chunk WHERE media_id = ?1", [&id_bytes]) + .map_err(map_sql)?; + conn.execute( + "INSERT INTO media + (id, kind, codec, storage, ext_path, total_len, channels, sample_rate, width, height) + VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7, ?8, ?9)", + rusqlite::params![ + id_bytes, + kind as i64, + codec, + MediaStorage::Referenced as i64, + ext_path, + meta.channels, + meta.sample_rate, + meta.width, + meta.height, + ], + ) + .map_err(map_sql)?; + Ok(()) +} + +fn set_project_json_conn(conn: &Connection, json: &str) -> Result<(), String> { + conn.execute( + "INSERT INTO project_json (id, data) VALUES (0, ?1) + ON CONFLICT(id) DO UPDATE SET data = excluded.data", + [json], + ) + .map_err(map_sql)?; + Ok(()) +} + +fn set_meta_conn(conn: &Connection, key: &str, value: &str) -> Result<(), String> { + conn.execute( + "INSERT INTO meta (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + rusqlite::params![key, value], + ) + .map_err(map_sql)?; + Ok(()) +} + +/// Read a packed media item's full bytes via a fresh **read-only** connection, +/// without an open [`BeamArchive`] handle. Returns `Ok(None)` if the item has no +/// chunk rows. Used for on-demand raster fault-in: a read-only connection can't +/// conflict with an in-place save and needs no long-lived handle. +pub fn read_packed_media_readonly(db_path: &Path, id: Uuid) -> Result>, String> { + let conn = Connection::open_with_flags( + db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(map_sql)?; + let id_bytes = id.as_bytes().to_vec(); + let mut stmt = conn + .prepare("SELECT bytes FROM media_chunk WHERE media_id = ?1 ORDER BY chunk_index") + .map_err(map_sql)?; + let rows = stmt + .query_map([&id_bytes], |r| r.get::<_, Vec>(0)) + .map_err(map_sql)?; + let mut out = Vec::new(); + let mut any = false; + for row in rows { + out.extend_from_slice(&row.map_err(map_sql)?); + any = true; + } + Ok(if any { Some(out) } else { None }) +} + +fn map_sql(e: rusqlite::Error) -> String { + format!("SQLite error: {}", e) +} + +fn uuid_from_bytes(bytes: &[u8]) -> Result { + let arr: [u8; 16] = bytes + .try_into() + .map_err(|_| format!("Invalid uuid blob length {}", bytes.len()))?; + Ok(Uuid::from_bytes(arr)) +} + +// Tests live in `tests/beam_archive.rs` (integration tests), so they compile the +// library in non-test mode and don't depend on the crate's other `#[cfg(test)]` +// modules. diff --git a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs index b8e83bf..cb9c839 100644 --- a/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs +++ b/lightningbeam-ui/lightningbeam-core/src/brush_engine.rs @@ -575,6 +575,27 @@ pub fn encode_png(img: &RgbaImage) -> Result, String> { Ok(buf.into_inner()) } +/// Long-edge cap (pixels) for raster keyframe proxies — low-res page-in placeholders. +pub const RASTER_PROXY_MAX_EDGE: u32 = 192; + +/// Downscale a full RGBA buffer to a low-res proxy PNG (long edge ≤ +/// `RASTER_PROXY_MAX_EDGE`). Returns `None` on invalid dims/length or encode failure. +pub fn encode_raster_proxy_png(raw: &[u8], width: u32, height: u32) -> Option> { + if width == 0 || height == 0 || raw.len() != (width as usize * height as usize * 4) { + return None; + } + let long = width.max(height); + let (pw, ph) = if long <= RASTER_PROXY_MAX_EDGE { + (width, height) + } else { + let s = RASTER_PROXY_MAX_EDGE as f32 / long as f32; + (((width as f32 * s).round() as u32).max(1), ((height as f32 * s).round() as u32).max(1)) + }; + let img = RgbaImage::from_raw(width, height, raw.to_vec())?; + let resized = image::imageops::resize(&img, pw, ph, image::imageops::FilterType::Triangle); + encode_png(&resized).ok() +} + /// Decode PNG bytes into an `RgbaImage` pub fn decode_png(data: &[u8]) -> Result { image::load_from_memory(data) diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index 68d6fb0..01e24dc 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -263,9 +263,11 @@ pub struct ImageAsset { /// Image height in pixels pub height: u32, - /// Embedded image data (for project portability) - /// If None, the image will be loaded from path when needed - #[serde(skip_serializing_if = "Option::is_none")] + /// Raw image file bytes. NOT serialized to project JSON — persisted as a + /// `MediaKind::ImageAsset` row in the `.beam` container (chunked, pageable) and + /// read back on load. `default` so new projects (bytes in the container, not JSON) + /// deserialize; old projects with base64-embedded `data` still load via deserialize. + #[serde(default, skip_serializing)] pub data: Option>, /// Folder this asset belongs to (None = root of category) @@ -902,7 +904,7 @@ mod tests { #[test] fn test_audio_clip_midi() { - let clip = AudioClip::new_midi("Piano Melody", 1, 60.0); + let clip = AudioClip::new_midi("Piano Melody", 1, daw_backend::Beats(60.0)); assert_eq!(clip.name, "Piano Melody"); assert_eq!(clip.duration, 60.0); match &clip.clip_type { @@ -952,7 +954,10 @@ mod tests { assert_eq!(instance.trim_start, 2.0); assert_eq!(instance.trim_end, Some(8.0)); - assert_eq!(instance.effective_duration(10.0), 6.0); + // At 60 BPM the tempo map is identity (1 beat == 1 second), so the + // beats-domain effective duration equals the seconds content window. + let tempo_map = crate::tempo_map::TempoMap::constant(60.0); + assert_eq!(instance.effective_duration(10.0, &tempo_map), 6.0); } #[test] @@ -963,7 +968,9 @@ mod tests { assert_eq!(instance.trim_start, 2.0); assert_eq!(instance.trim_end, None); - assert_eq!(instance.effective_duration(10.0), 8.0); + // At 60 BPM the tempo map is identity (1 beat == 1 second). + let tempo_map = crate::tempo_map::TempoMap::constant(60.0); + assert_eq!(instance.effective_duration(10.0, &tempo_map), 8.0); } #[test] diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index d8c9e3b..9fd4168 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -650,6 +650,29 @@ impl Document { layers } + /// Get mutable references to all layers across the entire document + /// (root + nested groups + inside all vector clips). Mirrors [`all_layers`]. + pub fn all_layers_mut(&mut self) -> Vec<&mut AnyLayer> { + let mut layers: Vec<&mut AnyLayer> = Vec::new(); + // Iterative walk with an explicit stack of child slices. Group layers are + // descended into but not themselves collected (they hold no keyframes). + let mut stack: Vec<&mut [AnyLayer]> = vec![&mut self.root.children]; + while let Some(list) = stack.pop() { + for layer in list { + match layer { + AnyLayer::Group(g) => { + stack.push(&mut g.children); + } + other => layers.push(other), + } + } + } + for clip in self.vector_clips.values_mut() { + layers.extend(clip.layers.root_data_mut()); + } + layers + } + // === CLIP LIBRARY METHODS === /// Add a vector clip to the library diff --git a/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs b/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs index fb919e8..3a830ba 100644 --- a/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/effect_layer.rs @@ -240,14 +240,18 @@ mod tests { let effect2 = def.create_instance(3.0, 7.0); // 3.0 + 7.0 = 10.0 end layer.add_clip_instance(effect2); + // At 60 BPM the tempo map is identity (1 beat == 1 second), so the + // query time in seconds equals the effect's beats-domain extents. + let tempo_map = crate::tempo_map::TempoMap::constant(60.0); + // At time 2: only effect1 active - assert_eq!(layer.active_clip_instances_at(2.0, 60.0).len(), 1); + assert_eq!(layer.active_clip_instances_at(2.0, &tempo_map).len(), 1); // At time 4: both effects active - assert_eq!(layer.active_clip_instances_at(4.0, 60.0).len(), 2); + assert_eq!(layer.active_clip_instances_at(4.0, &tempo_map).len(), 2); // At time 7: only effect2 active - assert_eq!(layer.active_clip_instances_at(7.0, 60.0).len(), 1); + assert_eq!(layer.active_clip_instances_at(7.0, &tempo_map).len(), 1); } #[test] diff --git a/lightningbeam-ui/lightningbeam-core/src/effect_registry.rs b/lightningbeam-ui/lightningbeam-core/src/effect_registry.rs index bdb2ec9..e477dca 100644 --- a/lightningbeam-ui/lightningbeam-core/src/effect_registry.rs +++ b/lightningbeam-ui/lightningbeam-core/src/effect_registry.rs @@ -75,7 +75,7 @@ impl EffectRegistry { INVERT_ID, "Invert", EffectCategory::Color, - include_str!("shaders/effect_invert.wgsl"), + format!("{}\n{}", crate::gpu::COLOR_WGSL, include_str!("shaders/effect_invert.wgsl")), vec![ EffectParameterDef::float_range("amount", "Amount", 1.0, 0.0, 1.0), ], @@ -88,7 +88,7 @@ impl EffectRegistry { BRIGHTNESS_CONTRAST_ID, "Brightness/Contrast", EffectCategory::Color, - include_str!("shaders/effect_brightness_contrast.wgsl"), + format!("{}\n{}", crate::gpu::COLOR_WGSL, include_str!("shaders/effect_brightness_contrast.wgsl")), vec![ EffectParameterDef::float_range("brightness", "Brightness", 0.0, -1.0, 1.0), EffectParameterDef::float_range("contrast", "Contrast", 1.0, 0.0, 3.0), @@ -102,7 +102,7 @@ impl EffectRegistry { HUE_SATURATION_ID, "Hue/Saturation", EffectCategory::Color, - include_str!("shaders/effect_hue_saturation.wgsl"), + format!("{}\n{}", crate::gpu::COLOR_WGSL, include_str!("shaders/effect_hue_saturation.wgsl")), vec![ EffectParameterDef::angle("hue", "Hue Shift", 0.0), EffectParameterDef::float_range("saturation", "Saturation", 1.0, 0.0, 3.0), diff --git a/lightningbeam-ui/lightningbeam-core/src/file_io.rs b/lightningbeam-ui/lightningbeam-core/src/file_io.rs index fe5fb74..c272537 100644 --- a/lightningbeam-ui/lightningbeam-core/src/file_io.rs +++ b/lightningbeam-ui/lightningbeam-core/src/file_io.rs @@ -1,20 +1,25 @@ //! File I/O for .beam project files //! -//! This module handles saving and loading Lightningbeam projects in the .beam format, -//! which is a ZIP archive containing: -//! - project.json (compressed) - Project metadata and structure -//! - media/ directory (uncompressed) - Embedded media files (FLAC for audio) +//! The `.beam` format is a single **SQLite database** (see [`crate::beam_archive`]): +//! - `project_json` table — serialized project metadata and structure +//! - `media` / `media_chunk` tables — audio and raster media (packed as chunked +//! blobs, or referenced by external path) +//! +//! Older `.beam` files are ZIP archives; [`load_beam`] detects and reads those +//! too (via [`load_beam_zip_legacy`]). Saving always writes the SQLite form, so +//! opening a legacy file and saving migrates it. +use crate::beam_archive::{BeamArchive, MediaKind, MediaMeta, LARGE_MEDIA_THRESHOLD}; use crate::document::Document; use daw_backend::audio::pool::AudioPoolEntry; use daw_backend::audio::project::Project as AudioProject; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; use std::fs::File; -use std::io::{Read, Write}; +use std::io::Read; use std::path::{Path, PathBuf}; -use zip::write::FileOptions; -use zip::{CompressionMethod, ZipArchive, ZipWriter}; -use flacenc::error::Verify; +use uuid::Uuid; +use zip::ZipArchive; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; /// File format version @@ -51,9 +56,7 @@ pub struct SerializedAudioBackend { /// Audio project (tracks, MIDI clips, etc.) pub project: AudioProject, - /// Audio pool entries (metadata and paths for audio files) - /// Note: embedded_data field from daw-backend is ignored; embedded files - /// are stored as FLAC in the ZIP's media/audio/ directory instead + /// Audio pool entries (metadata and media references for audio files) pub audio_pool_entries: Vec, /// Mapping from UI layer UUIDs to backend TrackIds @@ -63,6 +66,25 @@ pub struct SerializedAudioBackend { } +/// How to store a media file at or above [`LARGE_MEDIA_THRESHOLD`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum LargeMediaMode { + /// Not yet decided — prompt the user the first time a large file is imported. + /// Treated as [`LargeMediaMode::Reference`] at save time. Resetting the + /// preference to `Ask` re-triggers the prompt (useful for testing). + Ask, + /// Pack the bytes into the `.beam` container (chunked, streamed from disk). + Pack, + /// Keep the file external and store only a path reference. + Reference, +} + +impl Default for LargeMediaMode { + fn default() -> Self { + LargeMediaMode::Ask + } +} + /// Settings for saving a project #[derive(Debug, Clone)] pub struct SaveSettings { @@ -74,6 +96,10 @@ pub struct SaveSettings { /// Force linking all media files (don't embed any) pub force_link_all: bool, + + /// How to store files at/above [`LARGE_MEDIA_THRESHOLD`] (pack vs reference). + /// `Ask` behaves as `Reference` here (safe default: don't bloat the DB). + pub large_media_mode: LargeMediaMode, } impl Default for SaveSettings { @@ -82,6 +108,7 @@ impl Default for SaveSettings { auto_embed_threshold_bytes: 10_000_000, // 10 MB force_embed_all: false, force_link_all: false, + large_media_mode: LargeMediaMode::Ask, } } } @@ -100,6 +127,11 @@ pub struct LoadedProject { /// Loaded audio pool entries pub audio_pool_entries: Vec, + /// Persisted video-thumbnail packs by clip id (opaque LBTN blobs; decoded and + /// inserted into the VideoManager by the editor). Clips present here don't need + /// their thumbnails regenerated on load. + pub thumbnail_blobs: std::collections::HashMap>, + /// List of files that couldn't be found pub missing_files: Vec, } @@ -125,282 +157,345 @@ pub enum MediaFileType { Image, } -/// Save a project to a .beam file +/// Save a project to a `.beam` file (SQLite container). /// -/// This function: -/// 1. Prepares audio project for save (saves AudioGraph presets) -/// 2. Serializes project data to JSON -/// 3. Creates ZIP archive with compressed project.json -/// 4. Embeds media files as FLAC (for audio) in media/ directory +/// Re-saving an existing SQLite `.beam` updates it **in place** inside a single +/// transaction: unchanged (large) media is never rewritten, only changed rows +/// are touched, and the commit is atomic/crash-safe. A brand-new file or a +/// legacy-ZIP migration is written to a temp file and atomically renamed (there +/// is no large existing container to copy in that case). /// -/// # Arguments -/// * `path` - Path to save the .beam file -/// * `document` - UI document state -/// * `audio_project` - Audio backend project -/// * `audio_pool_entries` - Serialized audio pool entries -/// * `settings` - Save settings (embedding preferences) -/// -/// # Returns -/// Ok(()) on success, or error message +/// Audio and raster media become rows in the `media` table — packed as chunked +/// blobs, or referenced by external path for files at/above +/// [`LARGE_MEDIA_THRESHOLD`]. `project.json` goes in the `project_json` table. +/// Whether a stored media codec is an audio format the disk reader (Symphonia) +/// can stream directly from a packed blob. Video-container audio tracks and any +/// unknown formats fall back to the legacy reconstitution-and-decode path. +fn is_streamable_audio_codec(codec: &str) -> bool { + matches!( + codec.to_lowercase().as_str(), + "mp3" | "flac" | "ogg" | "oga" | "wav" | "wave" | "aiff" | "aif" + | "aac" | "m4a" | "opus" | "alac" | "caf" + ) +} + +/// A `Sync` wrapper over core's `BlobReader` so it satisfies Symphonia's +/// `MediaSource: Send + Sync`. `BlobReader` holds a rusqlite `Connection` +/// (`Send` but `!Sync`); the disk reader uses it single-threaded, so the +/// hot Read/Seek path goes through `Mutex::get_mut` (no runtime locking). +struct SyncBlobReader { + inner: std::sync::Mutex, + len: u64, +} + +impl std::io::Read for SyncBlobReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.inner.get_mut().unwrap().read(buf) + } +} +impl std::io::Seek for SyncBlobReader { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + self.inner.get_mut().unwrap().seek(pos) + } +} +impl daw_backend::audio::MediaByteSource for SyncBlobReader { + fn byte_len(&self) -> u64 { + self.len + } +} + +/// The host's packed-media byte-source factory: opens an independent streaming +/// reader over a `.beam` container's packed audio by media id. Installed into the +/// engine on load so container-packed audio streams without a full decode. +#[derive(Debug)] +struct BeamBlobFactory { + db_path: PathBuf, +} + +impl daw_backend::audio::AudioBlobSourceFactory for BeamBlobFactory { + fn open( + &self, + media_id: &str, + ) -> Result, String> { + let id = Uuid::parse_str(media_id).map_err(|e| format!("bad media id {}: {}", media_id, e))?; + let archive = BeamArchive::open(&self.db_path)?; + let reader = archive.open_blob_reader(&self.db_path, id)?; + let len = reader.len(); + Ok(Box::new(SyncBlobReader { inner: std::sync::Mutex::new(reader), len })) + } +} + +/// Build a packed-media byte-source factory for a `.beam` file, to install into +/// the engine (`EngineController::set_blob_source_factory`) before loading so its +/// packed audio can be streamed. +pub fn blob_source_factory( + beam_path: &Path, +) -> std::sync::Arc { + std::sync::Arc::new(BeamBlobFactory { db_path: beam_path.to_path_buf() }) +} + +/// Deterministic id for the waveform-pyramid media row of audio pool entry +/// `pool_index`, within a single project container. Stable across saves (so an +/// in-place re-save reuses the row instead of orphaning/rewriting it) and +/// independent of how the audio bytes are stored. The top 32 bits are a fixed +/// "LBWF" sentinel so it can't collide with the random v4 ids used elsewhere. +fn waveform_media_id(pool_index: usize) -> Uuid { + const SENTINEL: u128 = 0x4C42_5746u128 << 96; // "LBWF" in the high 32 bits + Uuid::from_u128(SENTINEL | (pool_index as u128)) +} + +/// Deterministic id for the thumbnail-pack media row of a video clip. Derived from +/// the clip id by XOR with a fixed constant — bijective and stable across saves, so +/// it reuses the row in place, and it can't (in practice) collide with the random +/// v4 ids used for other media of a different kind. +fn thumbnail_media_id(clip_id: Uuid) -> Uuid { + // "LBTN" repeated — moves clip ids into a distinct region of the id space. + const SENTINEL: u128 = 0x4C42_544E_4C42_544E_4C42_544E_4C42_544E; + Uuid::from_u128(clip_id.as_u128() ^ SENTINEL) +} + +/// Derived id for a raster keyframe's low-res proxy row (distinct from the keyframe's +/// own full-res `Raster` row, which is keyed by the raw keyframe id). +fn raster_proxy_media_id(kf_id: Uuid) -> Uuid { + // "LBPX" repeated — distinct id region from the full raster + thumbnail rows. + const SENTINEL: u128 = 0x4C42_5058_4C42_5058_4C42_5058_4C42_5058; + Uuid::from_u128(kf_id.as_u128() ^ SENTINEL) +} + pub fn save_beam( path: &Path, document: &Document, audio_project: &mut AudioProject, audio_pool_entries: Vec, layer_to_track_map: &std::collections::HashMap, + thumbnail_blobs: &std::collections::HashMap>, _settings: &SaveSettings, ) -> Result<(), String> { let fn_start = std::time::Instant::now(); - eprintln!("📊 [SAVE_BEAM] Starting save_beam()..."); + eprintln!("📊 [SAVE_BEAM] Starting save_beam() (SQLite container)..."); - // 1. Create backup if file exists and open it for reading old audio files - let step1_start = std::time::Instant::now(); - let mut old_zip = if path.exists() { - let backup_path = path.with_extension("beam.backup"); - std::fs::copy(path, &backup_path) - .map_err(|e| format!("Failed to create backup: {}", e))?; + let project_dir = path.parent().unwrap_or_else(|| Path::new(".")); + let in_place = path.exists() && BeamArchive::is_sqlite(path); - // Open the backup as a ZIP archive for reading - match File::open(&backup_path) { - Ok(file) => match ZipArchive::new(file) { - Ok(archive) => { - eprintln!("📊 [SAVE_BEAM] Step 1: Create backup and open for reading took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0); - Some(archive) - } - Err(e) => { - eprintln!("⚠️ [SAVE_BEAM] Failed to open backup as ZIP: {}, will not copy old audio files", e); - eprintln!("📊 [SAVE_BEAM] Step 1: Create backup took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0); - None - } - }, - Err(e) => { - eprintln!("⚠️ [SAVE_BEAM] Failed to open backup: {}, will not copy old audio files", e); - eprintln!("📊 [SAVE_BEAM] Step 1: Create backup took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0); - None - } - } + // In-place for an existing SQLite container (don't rewrite unchanged media); + // temp + atomic rename for new files / legacy-ZIP migration. + let tmp_path = path.with_extension("beam.tmp"); + let mut archive = if in_place { + BeamArchive::open(path)? } else { - eprintln!("📊 [SAVE_BEAM] Step 1: No backup needed (new file)"); - None + BeamArchive::create(&tmp_path)? }; - // 2. Graph presets are already populated by the engine thread (in GetProject handler) - // before cloning. Do NOT call prepare_for_save() here — the cloned project has - // default empty graphs (AudioTrack::clone() doesn't copy the graph), so calling - // prepare_for_save() would overwrite the good presets with empty ones. - let step2_start = std::time::Instant::now(); - eprintln!("📊 [SAVE_BEAM] Step 2: (graph presets already prepared) took {:.2}ms", step2_start.elapsed().as_secs_f64() * 1000.0); + let now = chrono::Utc::now().to_rfc3339(); + let created = if in_place { + archive.get_meta("created").ok().flatten().unwrap_or_else(|| now.clone()) + } else { + now.clone() + }; - // 3. Create ZIP writer - let step3_start = std::time::Instant::now(); - let file = File::create(path) - .map_err(|e| format!("Failed to create file: {}", e))?; - let mut zip = ZipWriter::new(file); - eprintln!("📊 [SAVE_BEAM] Step 3: Create ZIP writer took {:.2}ms", step3_start.elapsed().as_secs_f64() * 1000.0); + let txn = archive.transaction()?; - // 4. Process audio pool entries and write embedded audio files to ZIP - // Priority: old ZIP file > external file > encode PCM as FLAC - let step4_start = std::time::Instant::now(); - let mut modified_entries = Vec::new(); - let mut flac_encode_time = 0.0; - let mut zip_write_time = 0.0; - let project_dir = path.parent().unwrap_or_else(|| Path::new(".")); + // --- audio pool entries -> media rows (packed) or external references --- + let mut modified_entries = Vec::with_capacity(audio_pool_entries.len()); + let mut live_media: HashSet = HashSet::new(); for entry in &audio_pool_entries { - let mut modified_entry = entry.clone(); + let mut e = entry.clone(); + let existing_id = entry.media_id.as_ref().and_then(|s| Uuid::parse_str(s).ok()); - // Try to get audio data from various sources (in priority order) - let audio_source: Option<(Vec, String)> = if let Some(ref rel_path) = entry.relative_path { - // Priority 1: Check if file is in the old ZIP - if rel_path.starts_with("media/audio/") { - if let Some(ref mut old_zip_archive) = old_zip { - match old_zip_archive.by_name(rel_path) { - Ok(mut file) => { - let mut bytes = Vec::new(); - if file.read_to_end(&mut bytes).is_ok() { - let extension = rel_path.split('.').last().unwrap_or("bin").to_string(); - eprintln!("📊 [SAVE_BEAM] Copying from old ZIP: {}", rel_path); - Some((bytes, extension)) - } else { - eprintln!("⚠️ [SAVE_BEAM] Failed to read {} from old ZIP", rel_path); - None - } - } - Err(_) => { - eprintln!("⚠️ [SAVE_BEAM] File {} not found in old ZIP", rel_path); - None - } - } - } else { - None - } + // Already packed in this archive (in-place re-save): leave the bytes + // untouched, just keep the reference. + if let Some(id) = existing_id { + if txn.media_exists(id)? { + live_media.insert(id); + e.media_id = Some(id.to_string()); + e.relative_path = None; + e.embedded_data = None; + modified_entries.push(e); + continue; } - // Priority 2: Check external filesystem - else { - let full_path = project_dir.join(rel_path); - if full_path.exists() { - match std::fs::read(&full_path) { - Ok(bytes) => { - let extension = full_path.extension() - .and_then(|e| e.to_str()) - .unwrap_or("bin") - .to_string(); - eprintln!("📊 [SAVE_BEAM] Using external file: {:?}", full_path); - Some((bytes, extension)) - } - Err(e) => { - eprintln!("⚠️ [SAVE_BEAM] Failed to read {:?}: {}", full_path, e); - None - } - } - } else { - eprintln!("⚠️ [SAVE_BEAM] External file not found: {:?}", full_path); - None - } - } - } else { - None - }; - - if let Some((audio_bytes, extension)) = audio_source { - // We have the original file - copy it directly - let zip_filename = format!("media/audio/{}.{}", entry.pool_index, extension); - - let file_options = FileOptions::default() - .compression_method(CompressionMethod::Stored); - - zip.start_file(&zip_filename, file_options) - .map_err(|e| format!("Failed to create {} in ZIP: {}", zip_filename, e))?; - - let write_start = std::time::Instant::now(); - zip.write_all(&audio_bytes) - .map_err(|e| format!("Failed to write {}: {}", zip_filename, e))?; - zip_write_time += write_start.elapsed().as_secs_f64() * 1000.0; - - // Update entry to point to ZIP file - modified_entry.embedded_data = None; - modified_entry.relative_path = Some(zip_filename); - - } else if let Some(ref embedded_data) = entry.embedded_data { - // Priority 3: No original file - encode PCM as FLAC - eprintln!("📊 [SAVE_BEAM] Encoding PCM to FLAC for pool {} (no original file)", entry.pool_index); - // Embedded data is always PCM - encode as FLAC - let audio_bytes = BASE64_STANDARD.decode(&embedded_data.data_base64) - .map_err(|e| format!("Failed to decode base64 audio data for pool index {}: {}", entry.pool_index, e))?; - - let zip_filename = format!("media/audio/{}.flac", entry.pool_index); - - let file_options = FileOptions::default() - .compression_method(CompressionMethod::Stored); - - zip.start_file(&zip_filename, file_options) - .map_err(|e| format!("Failed to create {} in ZIP: {}", zip_filename, e))?; - - // Encode PCM samples to FLAC - let flac_start = std::time::Instant::now(); - - // The audio_bytes are raw PCM samples (interleaved f32 little-endian) - let samples: Vec = audio_bytes - .chunks_exact(4) - .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) - .collect(); - - // Convert f32 samples to i32 for FLAC encoding - let samples_i32: Vec = samples - .iter() - .map(|&s| { - let clamped = s.clamp(-1.0, 1.0); - (clamped * 8388607.0) as i32 - }) - .collect(); - - // Configure FLAC encoder - let config = flacenc::config::Encoder::default() - .into_verified() - .map_err(|(_, e)| format!("FLAC encoder config error: {:?}", e))?; - - let source = flacenc::source::MemSource::from_samples( - &samples_i32, - entry.channels as usize, - 24, - entry.sample_rate as usize, - ); - - // Encode to FLAC - let flac_stream = flacenc::encode_with_fixed_block_size( - &config, - source, - config.block_size, - ).map_err(|e| format!("FLAC encoding failed: {:?}", e))?; - - // Convert stream to bytes - use flacenc::component::BitRepr; - let mut sink = flacenc::bitsink::ByteSink::new(); - flac_stream.write(&mut sink) - .map_err(|e| format!("Failed to write FLAC stream: {:?}", e))?; - let flac_bytes = sink.as_slice(); - - flac_encode_time += flac_start.elapsed().as_secs_f64() * 1000.0; - - let write_start = std::time::Instant::now(); - zip.write_all(flac_bytes) - .map_err(|e| format!("Failed to write {}: {}", zip_filename, e))?; - zip_write_time += write_start.elapsed().as_secs_f64() * 1000.0; - - // Update entry to point to ZIP file instead of embedding data - modified_entry.embedded_data = None; - modified_entry.relative_path = Some(zip_filename); } - modified_entries.push(modified_entry); - } - eprintln!("📊 [SAVE_BEAM] Step 4: Process audio pool ({} entries) took {:.2}ms", - audio_pool_entries.len(), step4_start.elapsed().as_secs_f64() * 1000.0); - if flac_encode_time > 0.0 { - eprintln!("📊 [SAVE_BEAM] - FLAC encoding: {:.2}ms", flac_encode_time); - } - if zip_write_time > 0.0 { - eprintln!("📊 [SAVE_BEAM] - ZIP writing: {:.2}ms", zip_write_time); + // Otherwise resolve the source: external file (Priority 2, streamed from + // disk so a huge file is never fully loaded), or embedded data (Priority 3). + let meta = MediaMeta { + channels: Some(entry.channels), + sample_rate: Some(entry.sample_rate), + ..Default::default() + }; + let mut wrote_packed: Option = None; + let mut referenced: Option = None; + + if let Some(rel) = entry.relative_path.as_ref() { + let full = if Path::new(rel).is_absolute() { + PathBuf::from(rel) + } else { + project_dir.join(rel) + }; + // Require an actual file: an empty/blank `relative_path` resolves to the + // project directory itself (`join("")` == dir), which `exists()` accepts + // but can't be read as media. `is_file()` skips dirs + missing paths, so + // such an entry correctly falls through to embedded data below. + if full.is_file() { + let size = std::fs::metadata(&full).map(|m| m.len()).unwrap_or(0); + let codec = full + .extension() + .and_then(|x| x.to_str()) + .unwrap_or("bin") + .to_lowercase(); + // Video-audio entries are always referenced (the video is already + // referenced by its VideoClip; reloaded by re-probing via FFmpeg). + // Otherwise large files honor the user's pack-vs-reference choice + // (`Ask` == reference); smaller files are always packed. + let reference_it = entry.is_video_audio + || (size >= LARGE_MEDIA_THRESHOLD + && _settings.large_media_mode != LargeMediaMode::Pack); + if reference_it { + referenced = Some(rel.clone()); + } else { + let id = existing_id.unwrap_or_else(Uuid::new_v4); + txn.put_media_packed_from_path(id, MediaKind::Audio, &codec, &full, meta)?; + wrote_packed = Some(id); + } + } + } + + if wrote_packed.is_none() && referenced.is_none() { + if let Some(ed) = entry.embedded_data.as_ref() { + if let Ok(bytes) = BASE64_STANDARD.decode(&ed.data_base64) { + let id = existing_id.unwrap_or_else(Uuid::new_v4); + txn.put_media_packed(id, MediaKind::Audio, &ed.format.to_lowercase(), &bytes, meta)?; + wrote_packed = Some(id); + } + } + } + + if let Some(id) = wrote_packed { + live_media.insert(id); + e.media_id = Some(id.to_string()); + e.relative_path = None; + e.embedded_data = None; + } else if let Some(rel) = referenced { + e.media_id = None; + e.relative_path = Some(rel); + e.embedded_data = None; + } // else: nothing available — keep original references (reported missing on load) + + // Persist this entry's waveform pyramid (keyed by pool index, independent + // of the audio storage above). Reuse the row in place on re-save. + let wf_id = waveform_media_id(entry.pool_index); + if let Some(blob) = entry.waveform_blob.as_ref() { + txn.put_media_packed(wf_id, MediaKind::Waveform, "lbwf", blob, MediaMeta::default())?; + live_media.insert(wf_id); + } else if txn.media_exists(wf_id)? { + // Unchanged this save — keep the stored waveform row. + live_media.insert(wf_id); + } + + modified_entries.push(e); } - // 4b. Write raster layer PNG buffers to ZIP (media/raster/.png) - let step4b_start = std::time::Instant::now(); - let raster_file_options = FileOptions::default() - .compression_method(CompressionMethod::Stored); // PNG is already compressed + // --- raster keyframes -> media rows (PNG), keyed by keyframe id --- + // (Phase 0 writes all resident frames each save; a disk-dirty flag to skip + // unchanged frames in place is deferred to Phase 3.) + // Walk ALL layers (incl. nested in groups/clips) so nested raster keyframes + // are persisted too, and so `live_media` covers them — matching the load path, + // which arms `needs_fault_in` recursively. Top-level-only projects are unaffected. let mut raster_count = 0usize; - for layer in &document.root.children { + for layer in document.all_layers() { if let crate::layer::AnyLayer::Raster(rl) = layer { for kf in &rl.keyframes { if !kf.raw_pixels.is_empty() { - // Encode raw RGBA to PNG for storage - let img = crate::brush_engine::image_from_raw( - kf.raw_pixels.clone(), kf.width, kf.height, - ); + let img = + crate::brush_engine::image_from_raw(kf.raw_pixels.clone(), kf.width, kf.height); match crate::brush_engine::encode_png(&img) { Ok(png_bytes) => { - let zip_path = kf.media_path.clone(); - zip.start_file(&zip_path, raster_file_options) - .map_err(|e| format!("Failed to create {} in ZIP: {}", zip_path, e))?; - zip.write_all(&png_bytes) - .map_err(|e| format!("Failed to write {}: {}", zip_path, e))?; + txn.put_media_packed( + kf.id, + MediaKind::Raster, + "png", + &png_bytes, + MediaMeta { + width: Some(kf.width), + height: Some(kf.height), + ..Default::default() + }, + )?; + live_media.insert(kf.id); raster_count += 1; } - Err(e) => eprintln!("⚠️ [SAVE_BEAM] Failed to encode raster PNG {}: {}", kf.media_path, e), + Err(e) => eprintln!("⚠️ [SAVE_BEAM] Failed to encode raster {}: {}", kf.id, e), + } + // Low-res proxy alongside the full PNG (shown while the full pages + // in on a later load). Regenerated from the resident pixels. + let proxy_id = raster_proxy_media_id(kf.id); + if let Some(proxy_png) = + crate::brush_engine::encode_raster_proxy_png(&kf.raw_pixels, kf.width, kf.height) + { + txn.put_media_packed( + proxy_id, MediaKind::RasterProxy, "png", &proxy_png, MediaMeta::default(), + )?; + live_media.insert(proxy_id); + } + } else if txn.media_exists(kf.id)? { + // Pixels not resident but already stored — keep both rows. + live_media.insert(kf.id); + let proxy_id = raster_proxy_media_id(kf.id); + if txn.media_exists(proxy_id)? { + live_media.insert(proxy_id); } } } } } - eprintln!("📊 [SAVE_BEAM] Step 4b: Write {} raster PNG buffers took {:.2}ms", - raster_count, step4b_start.elapsed().as_secs_f64() * 1000.0); - // 5. Build BeamProject structure with modified entries - let step5_start = std::time::Instant::now(); - let now = chrono::Utc::now().to_rfc3339(); + // --- video thumbnail packs -> media rows (opaque LBTN blob), keyed by a + // sentinel-derived id from the video clip id --- + for clip_id in document.video_clips.keys() { + let tn_id = thumbnail_media_id(*clip_id); + if let Some(blob) = thumbnail_blobs.get(clip_id) { + txn.put_media_packed(tn_id, MediaKind::Thumbnail, "lbtn", blob, MediaMeta::default())?; + live_media.insert(tn_id); + } else if txn.media_exists(tn_id)? { + // Not regenerated this session — keep the stored pack. + live_media.insert(tn_id); + } + } + + // --- image assets -> media rows (original file bytes), keyed by asset id --- + let mut image_count = 0usize; + for (id, asset) in &document.image_assets { + if let Some(ref data) = asset.data { + let ext = asset + .path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("img") + .to_lowercase(); + txn.put_media_packed( + *id, + MediaKind::ImageAsset, + &ext, + data, + MediaMeta { width: Some(asset.width), height: Some(asset.height), ..Default::default() }, + )?; + live_media.insert(*id); + image_count += 1; + } else if txn.media_exists(*id)? { + // Bytes not resident (paged out) but already stored — keep the row. + live_media.insert(*id); + } + } + let _ = image_count; + + // --- orphan cleanup: drop media for removed clips/keyframes --- + let removed = txn.retain_media(&live_media)?; + + // --- project.json + meta --- let beam_project = BeamProject { version: BEAM_VERSION.to_string(), - created: now.clone(), - modified: now, + created: created.clone(), + modified: now.clone(), ui_state: document.clone(), audio_backend: SerializedAudioBackend { sample_rate: 48000, // TODO: Get from audio engine @@ -409,52 +504,222 @@ pub fn save_beam( layer_to_track_map: layer_to_track_map.clone(), }, }; - eprintln!("📊 [SAVE_BEAM] Step 5: Build BeamProject structure took {:.2}ms", step5_start.elapsed().as_secs_f64() * 1000.0); - - // 6. Write project.json (compressed with DEFLATE) - let step6_start = std::time::Instant::now(); - let json_options = FileOptions::default() - .compression_method(CompressionMethod::Deflated) - .compression_level(Some(6)); - - zip.start_file("project.json", json_options) - .map_err(|e| format!("Failed to create project.json in ZIP: {}", e))?; - - let json = serde_json::to_string_pretty(&beam_project) + let json = serde_json::to_string(&beam_project) .map_err(|e| format!("JSON serialization failed: {}", e))?; + txn.set_project_json(&json)?; + txn.set_meta("version", BEAM_VERSION)?; + txn.set_meta("created", &created)?; + txn.set_meta("modified", &now)?; + txn.commit()?; - zip.write_all(json.as_bytes()) - .map_err(|e| format!("Failed to write project.json: {}", e))?; - eprintln!("📊 [SAVE_BEAM] Step 6: Write project.json ({} bytes) took {:.2}ms", json.len(), step6_start.elapsed().as_secs_f64() * 1000.0); - - // 7. Finalize ZIP - let step7_start = std::time::Instant::now(); - zip.finish() - .map_err(|e| format!("Failed to finalize ZIP: {}", e))?; - eprintln!("📊 [SAVE_BEAM] Step 7: Finalize ZIP took {:.2}ms", step7_start.elapsed().as_secs_f64() * 1000.0); - - eprintln!("📊 [SAVE_BEAM] ✅ Total save_beam() time: {:.2}ms", fn_start.elapsed().as_secs_f64() * 1000.0); + // Close the connection before renaming (required on Windows; harmless elsewhere). + drop(archive); + if !in_place { + std::fs::rename(&tmp_path, path) + .map_err(|e| format!("Failed to finalize {:?}: {}", path, e))?; + } + eprintln!( + "📊 [SAVE_BEAM] ✅ Saved {} audio + {} raster media, {} orphans removed, in {:.2}ms", + audio_pool_entries.len(), + raster_count, + removed, + fn_start.elapsed().as_secs_f64() * 1000.0 + ); Ok(()) } -/// Load a project from a .beam file +/// Load a project from a `.beam` file. /// -/// This function: -/// 1. Opens ZIP archive and reads project.json -/// 2. Deserializes project data -/// 3. Loads embedded media files from archive -/// 4. Attempts to load external media files -/// 5. Rebuilds AudioGraphs from presets with correct sample_rate -/// -/// # Arguments -/// * `path` - Path to the .beam file -/// -/// # Returns -/// LoadedProject on success (with missing_files list), or error message +/// Detects the container format: SQLite (current) or legacy ZIP, and dispatches +/// accordingly. Both produce an identical [`LoadedProject`]. pub fn load_beam(path: &Path) -> Result { + if BeamArchive::is_sqlite(path) { + load_beam_sqlite(path) + } else { + load_beam_zip_legacy(path) + } +} + +/// Load a project from a SQLite `.beam` container. +/// +/// Phase 0 reconstitutes packed audio into each entry's `embedded_data` so the +/// existing (full-decode) audio pool loader keeps working unchanged; Phase 1b +/// replaces this with streaming reads via `BlobReader`. +fn load_beam_sqlite(path: &Path) -> Result { let fn_start = std::time::Instant::now(); - eprintln!("📊 [LOAD_BEAM] Starting load_beam()..."); + eprintln!("📊 [LOAD_BEAM] Starting load_beam() (SQLite container)..."); + + let archive = BeamArchive::open(path)?; + let json = archive.get_project_json()?; + let beam_project: BeamProject = serde_json::from_str(&json) + .map_err(|e| format!("Failed to deserialize project.json: {}", e))?; + + if beam_project.version != BEAM_VERSION { + return Err(format!( + "Unsupported file version: {} (expected {})", + beam_project.version, BEAM_VERSION + )); + } + + let mut document = beam_project.ui_state; + document.tempo_map_mut().rebuild_seconds(); + let mut audio_project = beam_project.audio_backend.project; + audio_project + .rebuild_audio_graphs(DEFAULT_BUFFER_SIZE) + .map_err(|e| format!("Failed to rebuild audio graphs: {}", e))?; + let layer_to_track_map = beam_project.audio_backend.layer_to_track_map; + + // For each packed audio item: stream it (leave `embedded_data` empty so the + // pool builds a Compressed placeholder backed by the blob factory) when it's a + // recognized audio codec; otherwise fall back to the legacy reconstitution + // (whole bytes → base64 → decode), which still covers video-container audio + // tracks symphonia can't stream and any unknown formats. + let mut restored_entries = Vec::with_capacity(beam_project.audio_backend.audio_pool_entries.len()); + for entry in &beam_project.audio_backend.audio_pool_entries { + let mut e = entry.clone(); + if let Some(id) = entry.media_id.as_ref().and_then(|s| Uuid::parse_str(s).ok()) { + match archive.media_info(id) { + Ok(Some(info)) => { + if is_streamable_audio_codec(&info.codec) { + // Stream: keep media_id, no embedded bytes. The engine opens + // the packed blob via the factory at activation time. + e.embedded_data = None; + e.relative_path = None; + } else { + match archive.read_media_full(id) { + Ok(bytes) => { + e.embedded_data = Some(daw_backend::audio::pool::EmbeddedAudioData { + data_base64: BASE64_STANDARD.encode(&bytes), + format: info.codec, + }); + e.relative_path = None; + } + Err(err) => eprintln!("⚠️ [LOAD_BEAM] Failed to read audio media {}: {}", id, err), + } + } + } + Ok(None) => eprintln!("⚠️ [LOAD_BEAM] Audio media {} missing from archive", id), + Err(err) => eprintln!("⚠️ [LOAD_BEAM] media_info({}) failed: {}", id, err), + } + } + + // Restore this entry's persisted waveform pyramid, if present — avoids + // re-decoding the source media just to redraw the overview. + let wf_id = waveform_media_id(entry.pool_index); + if let Ok(Some(_)) = archive.media_info(wf_id) { + match archive.read_media_full(wf_id) { + Ok(bytes) => e.waveform_blob = Some(bytes), + Err(err) => eprintln!("⚠️ [LOAD_BEAM] Failed to read waveform {}: {}", wf_id, err), + } + } + + restored_entries.push(e); + } + + // Raster keyframes are NOT eagerly decoded (Phase 3 paging): `raw_pixels` stays + // empty and is faulted in on demand from the container's `Raster` rows via the + // editor's `RasterStore` (keyed by `kf.id`). Loading a big paint project is now + // instant and only the resident window lives in RAM. Mark every keyframe + // `needs_fault_in` (recursively, incl. nested layers) so the renderer requests a + // page-in; a freshly-created keyframe stays `false` (blank-resident, nothing to load). + // Proxies (low-res, ~tens of KB each) ARE decoded eagerly so a cold scrub onto a + // not-yet-paged frame shows the proxy instantly instead of flashing blank. + let mut raster_load_count = 0usize; + let mut proxy_load_count = 0usize; + for layer in document.all_layers_mut() { + if let crate::layer::AnyLayer::Raster(rl) = layer { + for kf in &mut rl.keyframes { + kf.needs_fault_in = true; + raster_load_count += 1; + let proxy_id = raster_proxy_media_id(kf.id); + if let Ok(Some(_)) = archive.media_info(proxy_id) { + if let Ok(bytes) = archive.read_media_full(proxy_id) { + if let Ok(img) = crate::brush_engine::decode_png(&bytes) { + let (w, h) = (img.width(), img.height()); + kf.proxy = Some(crate::raster_layer::RasterProxy { + width: w, + height: h, + pixels: img.into_raw(), + }); + proxy_load_count += 1; + } + } + } + } + } + } + let _ = proxy_load_count; + + // Image-asset bytes are NOT eagerly read (Phase 4 Tier 1 paging): `ImageAsset.data` + // stays empty and the renderer's ImageCache pages bytes from the container on a + // decode miss (keyed by asset id). Old base64 projects keep their deserialized + // `data` (no container row). Loading is instant; only rendered images touch disk. + + // Missing external files (referenced entries whose file no longer exists). + let project_dir = path.parent().unwrap_or_else(|| Path::new(".")); + let missing_files: Vec = restored_entries + .iter() + .enumerate() + .filter_map(|(idx, entry)| { + if entry.embedded_data.is_none() && entry.media_id.is_none() { + if let Some(rel) = entry.relative_path.as_ref() { + let full = if Path::new(rel).is_absolute() { + PathBuf::from(rel) + } else { + project_dir.join(rel) + }; + if !full.exists() { + return Some(MissingFileInfo { + pool_index: idx, + original_path: full, + file_type: MediaFileType::Audio, + }); + } + } + } + None + }) + .collect(); + + // Persisted video thumbnail packs (opaque LBTN blobs), keyed by clip id. The + // editor decodes + inserts them and skips regeneration for these clips. + let mut thumbnail_blobs = std::collections::HashMap::new(); + for clip_id in document.video_clips.keys() { + let tn_id = thumbnail_media_id(*clip_id); + if let Ok(Some(info)) = archive.media_info(tn_id) { + if info.kind == MediaKind::Thumbnail { + match archive.read_media_full(tn_id) { + Ok(bytes) => { thumbnail_blobs.insert(*clip_id, bytes); } + Err(e) => eprintln!("⚠️ [LOAD_BEAM] Failed to read thumbnails for {}: {}", clip_id, e), + } + } + } + } + + eprintln!( + "📊 [LOAD_BEAM] ✅ Loaded {} audio entries, {} raster frames, {} thumbnail packs in {:.2}ms", + restored_entries.len(), + raster_load_count, + thumbnail_blobs.len(), + fn_start.elapsed().as_secs_f64() * 1000.0 + ); + + Ok(LoadedProject { + document, + audio_project, + layer_to_track_map, + audio_pool_entries: restored_entries, + thumbnail_blobs, + missing_files, + }) +} + +/// Load a project from a legacy ZIP `.beam` archive (pre-SQLite format). +/// Retained for backward compatibility; saving converts to SQLite. +fn load_beam_zip_legacy(path: &Path) -> Result { + let fn_start = std::time::Instant::now(); + eprintln!("📊 [LOAD_BEAM] Starting load_beam() (legacy ZIP)..."); // 1. Open ZIP archive let step1_start = std::time::Instant::now(); @@ -673,6 +938,7 @@ pub fn load_beam(path: &Path) -> Result { audio_project, layer_to_track_map, audio_pool_entries: restored_entries, + thumbnail_blobs: std::collections::HashMap::new(), // legacy ZIP has no thumbnail packs missing_files, }) } diff --git a/lightningbeam-ui/lightningbeam-core/src/gpu/color_convert.rs b/lightningbeam-ui/lightningbeam-core/src/gpu/color_convert.rs index 7f13e4f..9d78eca 100644 --- a/lightningbeam-ui/lightningbeam-core/src/gpu/color_convert.rs +++ b/lightningbeam-ui/lightningbeam-core/src/gpu/color_convert.rs @@ -6,6 +6,41 @@ use super::HDR_FORMAT; +/// Shared WGSL sRGB transfer functions — the single source of the sRGB OETF/EOTF +/// used by every gamma-aware shader. Prepend it to a shader's source (it defines +/// the functions before the body, so call order doesn't matter): +/// `srgb_to_linear_channel` / `linear_to_srgb_channel` (scalar) and +/// `srgb_to_linear` / `linear_to_srgb` (vec3). `linear_to_srgb_channel` clamps to +/// [0,1] (its outputs target 8-bit / SDR display surfaces). +pub const COLOR_WGSL: &str = r#" +fn srgb_to_linear_channel(c: f32) -> f32 { + return select(pow((c + 0.055) / 1.055, 2.4), c / 12.92, c <= 0.04045); +} +fn linear_to_srgb_channel(c: f32) -> f32 { + let x = clamp(c, 0.0, 1.0); + return select(1.055 * pow(x, 1.0 / 2.4) - 0.055, x * 12.92, x <= 0.0031308); +} +fn srgb_to_linear(c: vec3) -> vec3 { + return vec3(srgb_to_linear_channel(c.r), srgb_to_linear_channel(c.g), srgb_to_linear_channel(c.b)); +} +fn linear_to_srgb(c: vec3) -> vec3 { + return vec3(linear_to_srgb_channel(c.r), linear_to_srgb_channel(c.g), linear_to_srgb_channel(c.b)); +} +"#; + +/// sRGB → linear for one channel in `[0, 1]` (CPU twin of the WGSL +/// `srgb_to_linear_channel`). The single source of the EOTF for CPU code. +pub fn srgb_to_linear(c: f32) -> f32 { + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } +} + +/// linear → sRGB for one channel, clamped to `[0, 1]` (CPU twin of the WGSL +/// `linear_to_srgb_channel`). The single source of the OETF for CPU code. +pub fn linear_to_srgb(c: f32) -> f32 { + let c = c.clamp(0.0, 1.0); + if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 } +} + /// GPU pipeline for sRGB to linear color space conversion /// /// Converts Rgba8Srgb textures to Rgba16Float linear textures. diff --git a/lightningbeam-ui/lightningbeam-core/src/gpu/compositor.rs b/lightningbeam-ui/lightningbeam-core/src/gpu/compositor.rs index d434101..418c52f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/gpu/compositor.rs +++ b/lightningbeam-ui/lightningbeam-core/src/gpu/compositor.rs @@ -105,6 +105,9 @@ pub struct CompositorLayer { pub opacity: f32, /// Blend mode for this layer pub blend_mode: BlendMode, + /// Screen-blend RGB tint; `[0,0,0,0]` (the default) is a no-op. Used by + /// onion-skin ghosts (warm = past, cool = future). + pub tint: [f32; 4], } impl CompositorLayer { @@ -113,12 +116,19 @@ impl CompositorLayer { buffer, opacity: opacity.clamp(0.0, 1.0), blend_mode, + tint: [0.0; 4], } } pub fn normal(buffer: BufferHandle, opacity: f32) -> Self { Self::new(buffer, opacity, BlendMode::Normal) } + + /// Screen-blend the layer toward an RGB tint (for onion-skin ghosts). + pub fn with_tint(mut self, r: f32, g: f32, b: f32) -> Self { + self.tint = [r, g, b, 0.0]; + self + } } /// Uniform data for the composite shader @@ -129,8 +139,10 @@ pub struct CompositeUniforms { pub opacity: f32, /// Blend mode index pub blend_mode: u32, - /// Padding for alignment + /// Padding to 16 bytes before the vec4 tint pub _padding: [u32; 2], + /// Screen-blend tint ((0,0,0) = none); `.w` unused. + pub tint: [f32; 4], } /// Compositor for blending layers @@ -323,6 +335,7 @@ impl Compositor { opacity: layer.opacity, blend_mode: layer.blend_mode.to_index(), _padding: [0, 0], + tint: layer.tint, }; queue.write_buffer(&uniforms_buffer, 0, bytemuck::bytes_of(&uniforms)); @@ -382,6 +395,8 @@ struct Uniforms { opacity: f32, blend_mode: u32, _padding: vec2, + // Screen-blend tint ((0,0,0) = no tint). Used by onion-skin ghosts. + tint: vec4, } @group(0) @binding(0) var source_tex: texture_2d; @@ -526,7 +541,11 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { // Apply opacity let src_alpha = src.a * uniforms.opacity; + // Screen-blend tint (recolors blacks toward the tint; no-op at tint=0). + let t = uniforms.tint.rgb; + let tinted = src.rgb + t - src.rgb * t; + // Output premultiplied alpha in linear color space - return vec4(src.rgb * src_alpha, src_alpha); + return vec4(tinted * src_alpha, src_alpha); } "#; diff --git a/lightningbeam-ui/lightningbeam-core/src/gpu/mod.rs b/lightningbeam-ui/lightningbeam-core/src/gpu/mod.rs index c5ac285..2051002 100644 --- a/lightningbeam-ui/lightningbeam-core/src/gpu/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/gpu/mod.rs @@ -14,7 +14,7 @@ pub mod yuv_converter; // Re-export commonly used types pub use buffer_pool::{BufferHandle, BufferPool, BufferSpec, BufferFormat}; -pub use color_convert::SrgbToLinearConverter; +pub use color_convert::{SrgbToLinearConverter, COLOR_WGSL, srgb_to_linear, linear_to_srgb}; pub use compositor::{Compositor, CompositorLayer, BlendMode}; pub use effect_processor::{EffectProcessor, EffectUniforms}; pub use yuv_converter::YuvConverter; diff --git a/lightningbeam-ui/lightningbeam-core/src/layer.rs b/lightningbeam-ui/lightningbeam-core/src/layer.rs index 821bbe6..ef845c8 100644 --- a/lightningbeam-ui/lightningbeam-core/src/layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/layer.rs @@ -382,6 +382,29 @@ impl VectorLayer { self.keyframe_at(time).map(|kf| &kf.graph) } + /// The VectorGraph to *render* at `time`. When the keyframe at-or-before `time` has + /// `tween_after == Shape` and the next keyframe shares its topology, returns an owned + /// graph morphed between them; otherwise borrows the held keyframe's graph. Editing + /// should keep using `graph_at_time`/`graph_at_time_mut` (the held keyframe). + pub fn tweened_graph_at(&self, time: f64) -> Option> { + use std::borrow::Cow; + let idx = self.keyframes.partition_point(|kf| kf.time <= time); + if idx == 0 { + return None; + } + let a = &self.keyframes[idx - 1]; + if a.tween_after == TweenType::Shape && idx < self.keyframes.len() { + let b = &self.keyframes[idx]; + if b.time > a.time { + let t = ((time - a.time) / (b.time - a.time)).clamp(0.0, 1.0); + if let Some(g) = a.graph.interpolated(&b.graph, t) { + return Some(Cow::Owned(g)); + } + } + } + Some(Cow::Borrowed(&a.graph)) + } + /// Get a mutable VectorGraph at a given time pub fn graph_at_time_mut(&mut self, time: f64) -> Option<&mut VectorGraph> { self.keyframe_at_mut(time).map(|kf| &mut kf.graph) @@ -433,6 +456,25 @@ impl VectorLayer { time + frame_duration } + /// Start time of the group clip instance's visibility region that contains `time`: + /// the time of the earliest keyframe reachable by walking back through consecutive + /// keyframes that all contain the clip instance. Returns `None` if the clip instance + /// isn't a keyframe-gated group visible at `time` (e.g. a movie clip). + pub fn group_visibility_start(&self, clip_instance_id: &Uuid, time: f64) -> Option { + let after = self.keyframes.partition_point(|kf| kf.time <= time); + if after == 0 { + return None; + } + let mut idx = after - 1; // keyframe at-or-before `time` + if !self.keyframes[idx].clip_instance_ids.contains(clip_instance_id) { + return None; + } + while idx > 0 && self.keyframes[idx - 1].clip_instance_ids.contains(clip_instance_id) { + idx -= 1; + } + Some(self.keyframes[idx].time) + } + // Shape-based methods removed — use DCEL methods instead. // - shapes_at_time_mut → graph_at_time_mut // - get_shape_in_keyframe → use DCEL vertex/edge/face accessors @@ -451,30 +493,53 @@ impl VectorLayer { &mut self.keyframes[insert_idx] } - /// Insert a new keyframe at time by cloning the DCEL from the active keyframe. + /// Insert a new keyframe at time, taking the geometry the layer shows there. /// If a keyframe already exists at the exact time, does nothing and returns it. + /// + /// Inside a shape-tween span this captures the *interpolated* geometry at `time` (not + /// the left keyframe's), and inherits the span's `tween_after` so the new keyframe keeps + /// tweening toward the next one — i.e. splitting a tween in two leaves the motion intact. pub fn insert_keyframe_from_current(&mut self, time: f64) -> &mut ShapeKeyframe { let tolerance = 0.001; if let Some(idx) = self.keyframe_index_at_exact(time, tolerance) { return &mut self.keyframes[idx]; } - // Clone graph and clip instance IDs from the active keyframe - let (cloned_graph, cloned_clip_ids) = self + // Geometry shown at `time` (interpolated if mid-tween, else the held keyframe). + let cloned_graph = self + .tweened_graph_at(time) + .map(|g| g.into_owned()) + .unwrap_or_else(VectorGraph::new); + // Inherit tween + clip instances from the active (left) keyframe. + let (tween_after, cloned_clip_ids) = self .keyframe_at(time) - .map(|kf| { - (kf.graph.clone(), kf.clip_instance_ids.clone()) - }) - .unwrap_or_else(|| (VectorGraph::new(), Vec::new())); + .map(|kf| (kf.tween_after, kf.clip_instance_ids.clone())) + .unwrap_or((TweenType::None, Vec::new())); let insert_idx = self.keyframes.partition_point(|kf| kf.time < time); let mut kf = ShapeKeyframe::new(time); kf.graph = cloned_graph; + kf.tween_after = tween_after; kf.clip_instance_ids = cloned_clip_ids; self.keyframes.insert(insert_idx, kf); &mut self.keyframes[insert_idx] } + /// True when `time` falls strictly inside a shape-tween span — i.e. an in-between frame + /// (the left keyframe has `tween_after == Shape` and `time` is not on either keyframe). + /// Editing such a frame would silently modify the left keyframe, so the editor blocks it. + pub fn is_tween_inbetween(&self, time: f64) -> bool { + let tol = 0.001; + let idx = self.keyframes.partition_point(|kf| kf.time <= time); + if idx == 0 || idx >= self.keyframes.len() { + return false; + } + let (a, b) = (&self.keyframes[idx - 1], &self.keyframes[idx]); + a.tween_after == TweenType::Shape + && (time - a.time).abs() > tol + && (b.time - time).abs() > tol + } + /// Remove a keyframe at the exact time (within tolerance). /// Returns the removed keyframe if found. pub(crate) fn remove_keyframe_at(&mut self, time: f64, tolerance: f64) -> Option { @@ -1050,6 +1115,97 @@ impl AnyLayer { mod tests { use super::*; + #[test] + fn tweened_graph_at_morphs_between_shape_keyframes() { + use crate::vector_graph::{Direction, FillRule, ShapeColor}; + use kurbo::{CubicBez, Point}; + + // Build a single-vertex-ish graph at a given x via one degenerate fill is overkill; + // use one vertex + one edge (a loop) is also odd. Use two vertices + one edge and + // just check the vertex lerp through the layer's tween path. + let mk = |x: f64| { + let mut g = VectorGraph::new(); + let v0 = g.alloc_vertex(Point::new(x, 0.0)); + let v1 = g.alloc_vertex(Point::new(x + 10.0, 0.0)); + let c = CubicBez::new( + Point::new(x, 0.0), + Point::new(x + 3.0, 0.0), + Point::new(x + 7.0, 0.0), + Point::new(x + 10.0, 0.0), + ); + g.alloc_edge(c, v0, v1, None, Some(ShapeColor::rgb(0, 0, 0))); + g.alloc_fill(vec![(crate::vector_graph::EdgeId(0), Direction::Forward)], + ShapeColor::rgb(255, 0, 0), FillRule::NonZero); + g + }; + + let mut layer = VectorLayer::new("L"); + layer.keyframes.clear(); + let mut kf0 = ShapeKeyframe::new(0.0); + kf0.graph = mk(0.0); + kf0.tween_after = TweenType::Shape; + let mut kf10 = ShapeKeyframe::new(10.0); + kf10.graph = mk(100.0); + layer.keyframes.push(kf0); + layer.keyframes.push(kf10); + + // Midway through the tween, vertex 0 is halfway (x=50). + let g = layer.tweened_graph_at(5.0).unwrap(); + assert!((g.vertices[0].position.x - 50.0).abs() < 1e-6); + + // Without the tween flag, it holds the left keyframe (x=0). + layer.keyframes[0].tween_after = TweenType::None; + let g = layer.tweened_graph_at(5.0).unwrap(); + assert!((g.vertices[0].position.x - 0.0).abs() < 1e-6); + } + + #[test] + fn inserting_keyframe_mid_tween_captures_interpolated_geometry_and_inherits_tween() { + use crate::vector_graph::{Direction, FillRule, ShapeColor}; + use kurbo::{CubicBez, Point}; + + let mk = |x: f64| { + let mut g = VectorGraph::new(); + let v0 = g.alloc_vertex(Point::new(x, 0.0)); + let v1 = g.alloc_vertex(Point::new(x + 10.0, 0.0)); + let c = CubicBez::new( + Point::new(x, 0.0), + Point::new(x + 3.0, 0.0), + Point::new(x + 7.0, 0.0), + Point::new(x + 10.0, 0.0), + ); + g.alloc_edge(c, v0, v1, None, Some(ShapeColor::rgb(0, 0, 0))); + g.alloc_fill(vec![(crate::vector_graph::EdgeId(0), Direction::Forward)], + ShapeColor::rgb(255, 0, 0), FillRule::NonZero); + g + }; + + let mut layer = VectorLayer::new("L"); + layer.keyframes.clear(); + let mut kf_a = ShapeKeyframe::new(0.0); + kf_a.graph = mk(0.0); + kf_a.tween_after = TweenType::Shape; + let mut kf_b = ShapeKeyframe::new(10.0); + kf_b.graph = mk(100.0); + layer.keyframes.push(kf_a); + layer.keyframes.push(kf_b); + + // Insert keyframe C at the midpoint of the A→B shape tween. + layer.insert_keyframe_from_current(5.0); + + let c = layer.keyframe_at(5.0).expect("keyframe C exists"); + // C took the interpolated geometry (x=50), not A's geometry (x=0). + assert!((c.graph.vertices[0].position.x - 50.0).abs() < 1e-6, + "C should hold interpolated geometry, got {}", c.graph.vertices[0].position.x); + // C inherits the shape tween so it still morphs toward B. + assert_eq!(c.tween_after, TweenType::Shape, "C should inherit the shape tween"); + + // The frame between C and B is still an in-between (tween continues past C). + assert!(layer.is_tween_inbetween(7.0)); + // C itself is a keyframe, not an in-between. + assert!(!layer.is_tween_inbetween(5.0)); + } + #[test] fn test_layer_creation() { let layer = Layer::new(LayerType::Vector, "Test Layer"); diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index 40ce30f..db5a9d1 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -42,6 +42,7 @@ pub mod segment_builder; pub mod planar_graph; pub mod file_types; pub mod file_io; +pub mod beam_archive; pub mod export; pub mod clipboard; pub(crate) mod clipboard_platform; @@ -53,6 +54,7 @@ pub mod svg_export; pub mod snap; pub mod webcam; pub mod raster_layer; +pub mod raster_store; pub mod brush_settings; pub mod brush_engine; pub mod raster_draw; diff --git a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs index 52f3ac7..b4c858a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/raster_layer.rs @@ -92,6 +92,16 @@ impl Default for TweenType { } } +/// A low-res decoded RGBA proxy of a keyframe's pixels, shown while the full-res +/// buffer pages in from the container so cold scrubs don't flash blank. +#[derive(Clone, Debug)] +pub struct RasterProxy { + pub width: u32, + pub height: u32, + /// RGBA, `width * height * 4` bytes. + pub pixels: Vec, +} + /// A single keyframe of a raster layer #[derive(Clone, Debug, Serialize, Deserialize)] pub struct RasterKeyframe { @@ -117,6 +127,24 @@ pub struct RasterKeyframe { /// Always `true` after load; cleared by the renderer after uploading. #[serde(skip, default = "default_true")] pub texture_dirty: bool, + /// Phase 3 paging: the keyframe's pixels live in the container and must be + /// faulted in (`raw_pixels` empty *and* this true ⇒ page in from the store). + /// A *new* keyframe is `false` (intentionally blank/resident, nothing to load); + /// set true on load and again when evicted. Never serialized. + #[serde(skip)] + pub needs_fault_in: bool, + /// Phase 3a eviction: set `true` whenever user editing mutates `raw_pixels` + /// (brush, fill, paint-bucket, floating-selection commit/lift, undo/redo of + /// those). A dirty keyframe's current pixels are NOT yet persisted in the + /// container, so it must NEVER be evicted (doing so would silently lose the + /// unsaved edit). Cleared on a successful save. Never serialized. + #[serde(skip)] + pub dirty: bool, + /// Phase 3a-3: low-res proxy decoded from the container on load, rendered while + /// the full pixels page in (removes the cold-scrub blank flash). `None` if the + /// keyframe has no persisted proxy yet (new/unsaved, or pre-proxy project). + #[serde(skip)] + pub proxy: Option, } fn default_true() -> bool { true } @@ -140,6 +168,9 @@ impl RasterKeyframe { tween_after: TweenType::Hold, raw_pixels: Vec::new(), texture_dirty: true, + needs_fault_in: false, + dirty: false, + proxy: None, } } } @@ -204,6 +235,35 @@ impl RasterLayer { &mut self.keyframes[insert_idx] } + /// Insert a blank keyframe at `time` if none exists there (within tolerance). + /// Returns the new keyframe's id if one was created, `None` if a keyframe already + /// existed. Used by the explicit "New Keyframe" command (blank cel). + pub fn insert_blank_keyframe_at(&mut self, time: f64, width: u32, height: u32) -> Option { + if self.keyframe_index_at_exact(time, 0.001).is_some() { + return None; + } + let (w, h) = if width == 0 || height == 0 { + self.keyframe_at(time) + .map(|kf| (kf.width, kf.height)) + .unwrap_or((1920, 1080)) + } else { + (width, height) + }; + let insert_idx = self.keyframes.partition_point(|kf| kf.time < time); + let kf = RasterKeyframe::new(time, w, h); + let id = kf.id; + self.keyframes.insert(insert_idx, kf); + Some(id) + } + + /// Remove the keyframe with the given id, returning it if found. + pub fn remove_keyframe(&mut self, id: Uuid) -> Option { + self.keyframes + .iter() + .position(|kf| kf.id == id) + .map(|pos| self.keyframes.remove(pos)) + } + /// Return the ZIP-relative PNG path for the active keyframe at `time`, or `None`. pub fn buffer_path_at_time(&self, time: f64) -> Option<&str> { self.keyframe_at(time).map(|kf| kf.media_path.as_str()) diff --git a/lightningbeam-ui/lightningbeam-core/src/raster_store.rs b/lightningbeam-ui/lightningbeam-core/src/raster_store.rs new file mode 100644 index 0000000..c597971 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/raster_store.rs @@ -0,0 +1,56 @@ +//! On-demand loader for raster keyframe pixels backed by the project `.beam` +//! container (Phase 3 paging). +//! +//! Raster keyframes are no longer eagerly decoded at load; `raw_pixels` stays +//! empty until something needs the frame, then it is faulted in from the +//! container's `Raster` media row (keyed by the keyframe id). The store holds only +//! the container path and reads through a fresh **read-only** connection per call, +//! so it never conflicts with an in-place save and keeps no long-lived handle. +//! `None` path = an unsaved document (nothing to fault in). + +use std::path::PathBuf; +use uuid::Uuid; + +/// Faults in raster keyframe pixels from the project container on demand. +#[derive(Default, Clone)] +pub struct RasterStore { + path: Option, +} + +impl RasterStore { + pub fn new(path: Option) -> Self { + Self { path } + } + + /// Point the store at a (possibly new) container path, or `None` for an + /// unsaved document. Call on load and on save-as. + pub fn set_path(&mut self, path: Option) { + self.path = path; + } + + pub fn has_path(&self) -> bool { + self.path.is_some() + } + + /// Decode the keyframe's full RGBA pixels from the container, or `None` if the + /// container has no row for it (or decoding fails). The returned buffer is the + /// working `raw_pixels` representation (`width*height*4` sRGB-premultiplied RGBA). + pub fn load_pixels(&self, kf_id: Uuid) -> Option> { + let path = self.path.as_ref()?; + let png = match crate::beam_archive::read_packed_media_readonly(path, kf_id) { + Ok(Some(bytes)) => bytes, + Ok(None) => return None, + Err(e) => { + eprintln!("[RasterStore] read {} failed: {}", kf_id, e); + return None; + } + }; + match crate::brush_engine::decode_png(&png) { + Ok(img) => Some(img.into_raw()), + Err(e) => { + eprintln!("[RasterStore] decode {} failed: {}", kf_id, e); + None + } + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 674495a..7186606 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -22,43 +22,111 @@ use vello::peniko::{Blob, Fill, ImageAlphaType, ImageBrush, ImageData, ImageForm use vello::Scene; /// Cache for decoded image data to avoid re-decoding every frame +/// Decoded-image cache, bounded by a byte budget with usage-LRU eviction (Phase 4 +/// asset paging). The decoded RGBA (~`w·h·4` per image) is the heavy, evictable cost; +/// a miss re-decodes from `asset.data`. Recency is bumped on every access, so images +/// actually rendered each frame stay resident and unused ones age out under pressure. pub struct ImageCache { cache: HashMap>, /// CPU path: tiny-skia pixmaps decoded from the same assets (premultiplied RGBA8) cpu_cache: HashMap>, + /// Recency order (least-recent first) of resident asset ids. + lru: Vec, + /// Decoded bytes per resident asset (counted once; GPU/CPU are ~equal and a render + /// session uses one path) and the running total. + sizes: HashMap, + bytes: usize, + /// `.beam` container path for lazily loading compressed `ImageAsset` bytes on a + /// decode miss (Tier 1 paging) when `asset.data` isn't resident. + container_path: Option, } impl ImageCache { + /// Max decoded-image bytes kept resident before LRU eviction. + const BUDGET: usize = 256 * 1024 * 1024; + /// Create a new empty image cache pub fn new() -> Self { Self { cache: HashMap::new(), cpu_cache: HashMap::new(), + lru: Vec::new(), + sizes: HashMap::new(), + bytes: 0, + container_path: None, + } + } + + /// Set the `.beam` container path used to lazily load image bytes that aren't + /// resident in `asset.data` (Tier 1 paging). Cheap to call each frame. + pub fn set_container_path(&mut self, path: Option) { + self.container_path = path; + } + + /// Resolve an asset's compressed bytes: prefer the resident `asset.data` (imported + /// this session, or an old base64 project), else page from the container. + fn resolve_bytes<'a>(&self, asset: &'a ImageAsset) -> Option> { + if let Some(d) = &asset.data { + return Some(std::borrow::Cow::Borrowed(d.as_slice())); + } + let path = self.container_path.as_ref()?; + crate::beam_archive::read_packed_media_readonly(path, asset.id) + .ok() + .flatten() + .map(std::borrow::Cow::Owned) + } + + /// Mark `id` (size `size` bytes) as most-recently-used; evict LRU entries over budget. + fn touch(&mut self, id: Uuid, size: usize) { + if !self.sizes.contains_key(&id) { + self.sizes.insert(id, size); + self.bytes += size; + } + if let Some(pos) = self.lru.iter().position(|x| *x == id) { + self.lru.remove(pos); + } + self.lru.push(id); + // Keep at least the just-touched entry resident. + while self.bytes > Self::BUDGET && self.lru.len() > 1 { + let old = self.lru.remove(0); + self.cache.remove(&old); + self.cpu_cache.remove(&old); + if let Some(sz) = self.sizes.remove(&old) { + self.bytes -= sz; + } } } /// Get or decode an image, caching the result pub fn get_or_decode(&mut self, asset: &ImageAsset) -> Option> { - if let Some(cached) = self.cache.get(&asset.id) { - return Some(Arc::clone(cached)); + let size = (asset.width as usize) * (asset.height as usize) * 4; + if let Some(cached) = self.cache.get(&asset.id).map(Arc::clone) { + self.touch(asset.id, size); + return Some(cached); } - // Decode and cache - let image = decode_image_asset(asset)?; + // Decode and cache (bytes from asset.data or paged from the container). + let bytes = self.resolve_bytes(asset)?; + let image = decode_image_brush(&bytes)?; let arc_image = Arc::new(image); self.cache.insert(asset.id, Arc::clone(&arc_image)); + self.touch(asset.id, size); Some(arc_image) } /// Get or decode an image as a premultiplied tiny-skia Pixmap (CPU render path). pub fn get_or_decode_cpu(&mut self, asset: &ImageAsset) -> Option> { - if let Some(cached) = self.cpu_cache.get(&asset.id) { - return Some(Arc::clone(cached)); + let size = (asset.width as usize) * (asset.height as usize) * 4; + if let Some(cached) = self.cpu_cache.get(&asset.id).map(Arc::clone) { + self.touch(asset.id, size); + return Some(cached); } - let pixmap = decode_image_to_pixmap(asset)?; + let bytes = self.resolve_bytes(asset)?; + let pixmap = decode_image_to_pixmap(&bytes)?; let arc = Arc::new(pixmap); self.cpu_cache.insert(asset.id, Arc::clone(&arc)); + self.touch(asset.id, size); Some(arc) } @@ -66,12 +134,21 @@ impl ImageCache { pub fn invalidate(&mut self, id: &Uuid) { self.cache.remove(id); self.cpu_cache.remove(id); + if let Some(pos) = self.lru.iter().position(|x| x == id) { + self.lru.remove(pos); + } + if let Some(sz) = self.sizes.remove(id) { + self.bytes -= sz; + } } /// Clear all cached images pub fn clear(&mut self) { self.cache.clear(); self.cpu_cache.clear(); + self.lru.clear(); + self.sizes.clear(); + self.bytes = 0; } } @@ -81,12 +158,34 @@ impl Default for ImageCache { } } -/// Decode an image asset to a premultiplied tiny-skia Pixmap (CPU render path). -fn decode_image_to_pixmap(asset: &ImageAsset) -> Option { - let data = asset.data.as_ref()?; +/// Image asset ids referenced by the visible vector layers' active keyframes at `time` +/// (top-level + group children). Used to prefetch/decode images ahead during playback. +/// (Recursing into nested clip instances is a refinement.) +pub fn assets_needed_at(document: &Document, time: f64) -> Vec { + let mut ids = Vec::new(); + for layer in document.all_layers() { + if let crate::layer::AnyLayer::Vector(vl) = layer { + if !vl.layer.visible { + continue; + } + if let Some(kf) = vl.keyframe_at(time) { + for fill in &kf.graph.fills { + if let Some(id) = fill.image_fill { + ids.push(id); + } + } + } + } + } + ids +} + +/// Decode image bytes to a premultiplied tiny-skia Pixmap (CPU render path). +fn decode_image_to_pixmap(data: &[u8]) -> Option { let img = image::load_from_memory(data).ok()?; let rgba = img.to_rgba8(); - let mut pixmap = tiny_skia::Pixmap::new(asset.width, asset.height)?; + let (iw, ih) = rgba.dimensions(); + let mut pixmap = tiny_skia::Pixmap::new(iw, ih)?; for (dst, src) in pixmap.pixels_mut().iter_mut().zip(rgba.pixels()) { let [r, g, b, a] = src.0; // Convert straight alpha (image crate output) to premultiplied (tiny-skia internal format) @@ -100,21 +199,17 @@ fn decode_image_to_pixmap(asset: &ImageAsset) -> Option { Some(pixmap) } -/// Decode an image asset to peniko ImageBrush -fn decode_image_asset(asset: &ImageAsset) -> Option { - // Get the raw file data - let data = asset.data.as_ref()?; - - // Decode using the image crate +/// Decode image bytes to a peniko ImageBrush (GPU render path). +fn decode_image_brush(data: &[u8]) -> Option { let img = image::load_from_memory(data).ok()?; let rgba = img.to_rgba8(); + let (iw, ih) = rgba.dimensions(); - // Create peniko ImageData then ImageBrush let image_data = ImageData { data: Blob::from(rgba.into_raw()), format: ImageFormat::Rgba8, - width: asset.width, - height: asset.height, + width: iw, + height: ih, alpha_type: ImageAlphaType::Alpha, }; Some(ImageBrush::new(image_data)) @@ -423,11 +518,25 @@ pub fn render_layer_isolated( * Affine::scale_non_uniform(scale_x, scale_y) * skew_transform; + // The decoded frame is scaled down to fit the document (decoder caps + // at the canvas size), so its pixel size is smaller than the clip's + // native dimensions. The instance is blitted treating the texture as + // `frame.width × frame.height`, while `clip_transform` is expressed in + // the clip's native space — so scale frame-px → clip-native-px first, + // else the frame renders small in a corner with its edges streaked. + let frame_to_clip = if frame.width > 0 && frame.height > 0 { + Affine::scale_non_uniform( + video_clip.width / frame.width as f64, + video_clip.height / frame.height as f64, + ) + } else { + Affine::IDENTITY + }; instances.push(VideoRenderInstance { rgba_data: frame.rgba_data.clone(), width: frame.width, height: frame.height, - transform: base_transform * clip_transform, + transform: base_transform * clip_transform * frame_to_clip, opacity: (layer_opacity * inst_opacity) as f32, }); } @@ -1028,12 +1137,26 @@ fn render_video_layer( // Create rectangle path for the video frame let video_rect = Rect::new(0.0, 0.0, video_clip.width, video_clip.height); + // The decoded frame is scaled down to fit the document (the decoder caps at + // the canvas size to bound memory), so its pixel dimensions are smaller than + // the clip's native display size. Scale the image brush from frame-pixel + // space to the clip rect; without this the image is drawn 1:1 in a corner + // and its edge pixels pad the rest (small frame with "stretched corners"). + let brush_transform = if frame.width > 0 && frame.height > 0 { + Affine::scale_non_uniform( + video_clip.width / frame.width as f64, + video_clip.height / frame.height as f64, + ) + } else { + Affine::IDENTITY + }; + // Render video frame as image fill scene.fill( Fill::NonZero, instance_transform, &image_with_alpha, - None, + Some(brush_transform), &video_rect, ); clip_rendered = true; @@ -1134,7 +1257,15 @@ pub fn render_vector_graph( if let Some(image_asset) = document.get_image_asset(&image_asset_id) { if let Some(image) = image_cache.get_or_decode(image_asset) { let image_with_alpha = (*image).clone().with_alpha(opacity_f32); - scene.fill(fill_rule, base_transform, &image_with_alpha, None, &path); + // Map the image (native pixel space, origin 0,0) onto the fill's + // bounding box, so it sits where the shape is and scales to fit + // (1:1 for an image-sized rectangle). + let bbox = vello::kurbo::Shape::bounding_box(&path); + let iw = (image_asset.width.max(1)) as f64; + let ih = (image_asset.height.max(1)) as f64; + let brush_transform = Affine::translate((bbox.x0, bbox.y0)) + * Affine::scale_non_uniform(bbox.width() / iw, bbox.height() / ih); + scene.fill(fill_rule, base_transform, &image_with_alpha, Some(brush_transform), &path); filled = true; } } @@ -1225,7 +1356,12 @@ fn render_vector_layer( // Cascade opacity: parent_opacity × layer.opacity let layer_opacity = parent_opacity * layer.layer.opacity; - // Render clip instances first (they appear under shape instances) + // Render the layer's own VectorGraph (loose shapes) first, then clip instances + // (groups / movie clips) on top. Shape tweens are applied here. + if let Some(graph) = layer.tweened_graph_at(time) { + render_vector_graph(&graph, scene, base_transform, layer_opacity, document, image_cache); + } + for clip_instance in &layer.clip_instances { // For groups, compute the visibility end from keyframe data let group_end_time = document.vector_clips.get(&clip_instance.clip_id) @@ -1236,11 +1372,6 @@ fn render_vector_layer( }); render_clip_instance(document, time, clip_instance, layer_opacity, scene, base_transform, &layer.layer.animation_data, image_cache, video_manager, group_end_time); } - - // Render VectorGraph from active keyframe - if let Some(graph) = layer.graph_at_time(time) { - render_vector_graph(graph, scene, base_transform, layer_opacity, document, image_cache); - } } // ============================================================================ @@ -1458,12 +1589,21 @@ fn render_vector_graph_cpu( if let Some(image_asset_id) = fill.image_fill { if let Some(asset) = document.get_image_asset(&image_asset_id) { if let Some(img_pixmap) = image_cache.get_or_decode_cpu(asset) { + // Map the image's native pixel space onto the fill's bounding box. + let bbox: kurbo::Rect = vello::kurbo::Shape::bounding_box(&path); + let iw = (asset.width.max(1)) as f32; + let ih = (asset.height.max(1)) as f32; + let sx = (bbox.width() as f32) / iw; + let sy = (bbox.height() as f32) / ih; + let pat_tf = tiny_skia::Transform::from_row( + sx, 0.0, 0.0, sy, bbox.x0 as f32, bbox.y0 as f32, + ); let pattern = tiny_skia::Pattern::new( tiny_skia::Pixmap::as_ref(&img_pixmap), tiny_skia::SpreadMode::Pad, tiny_skia::FilterQuality::Bilinear, opacity, - tiny_skia::Transform::identity(), + pat_tf, ); let mut paint = tiny_skia::Paint::default(); paint.shader = pattern; @@ -1527,6 +1667,11 @@ fn render_vector_layer_cpu( ) { let layer_opacity = parent_opacity * layer.layer.opacity; + // Loose shapes first, then clip instances (groups / movie clips) on top. + if let Some(graph) = layer.tweened_graph_at(time) { + render_vector_graph_cpu(&graph, pixmap, affine_to_ts(base_transform), layer_opacity as f32, document, image_cache); + } + for clip_instance in &layer.clip_instances { let group_end_time = document.vector_clips.get(&clip_instance.clip_id) .filter(|vc| vc.is_group) @@ -1539,10 +1684,6 @@ fn render_vector_layer_cpu( &layer.layer.animation_data, image_cache, group_end_time, ); } - - if let Some(graph) = layer.graph_at_time(time) { - render_vector_graph_cpu(graph, pixmap, affine_to_ts(base_transform), layer_opacity as f32, document, image_cache); - } } /// Render a clip instance (and its nested layers) to a CPU pixmap. diff --git a/lightningbeam-ui/lightningbeam-core/src/selection.rs b/lightningbeam-ui/lightningbeam-core/src/selection.rs index 0da1b5b..af4da81 100644 --- a/lightningbeam-ui/lightningbeam-core/src/selection.rs +++ b/lightningbeam-ui/lightningbeam-core/src/selection.rs @@ -4,9 +4,8 @@ use crate::vector_graph::{VectorGraph, EdgeId, FillId, VertexId}; use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use uuid::Uuid; -use vello::kurbo::{Affine, BezPath}; /// Shape of a raster pixel selection, in canvas pixel coordinates. #[derive(Clone, Debug)] @@ -440,42 +439,6 @@ impl Selection { } } -/// Represents a temporary region-based selection. -/// -/// When a region select is active, the region boundary is inserted into the -/// DCEL as invisible edges, splitting existing geometry. Faces inside the -/// region are added to the normal `Selection`. If the user performs an -/// operation, the selection is committed; if they deselect, the DCEL is -/// restored from the snapshot. -#[derive(Clone, Debug)] -pub struct RegionSelection { - /// The clipping region as a closed BezPath (polygon or rect) - pub region_path: BezPath, - /// Layer containing the affected elements - pub layer_id: Uuid, - /// Keyframe time - pub time: f64, - /// Snapshot of the graph before region boundary insertion, for revert - pub graph_snapshot: VectorGraph, - /// The extracted graph containing geometry inside the region - pub selected_graph: VectorGraph, - /// Transform applied to the selected graph (e.g. from dragging) - pub transform: Affine, - /// Whether the selection has been committed (via an operation on the selection) - pub committed: bool, - /// IDs of the invisible edges inserted for the region boundary stroke. - /// These exist in the main graph (remainder side). Deleted during merge-back. - pub region_edge_ids: Vec, - /// Action epoch recorded when this selection was created. - /// Compared against `ActionExecutor::epoch()` on deselect to decide - /// whether merge-back is needed or a clean snapshot restore suffices. - pub action_epoch_at_selection: u64, - /// selected_graph VID → main graph VID for boundary vertices (shared between both graphs). - pub boundary_vertex_map: HashMap, - /// selected_graph boundary EID → main graph boundary EID (duplicated edges to skip on merge). - pub boundary_edge_map: HashMap, -} - #[cfg(test)] mod tests { use super::*; diff --git a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_brightness_contrast.wgsl b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_brightness_contrast.wgsl index 92c2350..c8fafa8 100644 --- a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_brightness_contrast.wgsl +++ b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_brightness_contrast.wgsl @@ -31,14 +31,23 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { return out; } +// The HDR pipeline feeds these shaders LINEAR light, but brightness/contrast +// (additive brightness, contrast pivoting around 0.5 perceptual mid-gray) are +// defined in gamma/display space. Convert to sRGB, adjust there, then convert +// back to linear so the controls behave like standard editors. +// sRGB helpers (linear_to_srgb / srgb_to_linear) come from the prepended +// COLOR_WGSL prelude. + @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { let src = textureSample(source_tex, source_sampler, in.uv); let brightness = uniforms.params0.x; // -1 to 1 let contrast = uniforms.params0.y; // 0 to 3 + let src_srgb = linear_to_srgb(src.rgb); + // Apply brightness (additive) - var color = src.rgb + vec3(brightness); + var color = src_srgb + vec3(brightness); // Apply contrast (multiply around midpoint 0.5) color = (color - vec3(0.5)) * contrast + vec3(0.5); @@ -46,6 +55,6 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { // Clamp to valid range color = clamp(color, vec3(0.0), vec3(1.0)); - let result = mix(src.rgb, color, uniforms.mix); - return vec4(result, src.a); + let result_srgb = mix(src_srgb, color, uniforms.mix); + return vec4(srgb_to_linear(result_srgb), src.a); } diff --git a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_hue_saturation.wgsl b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_hue_saturation.wgsl index 3553417..6b1425a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_hue_saturation.wgsl +++ b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_hue_saturation.wgsl @@ -84,6 +84,12 @@ fn hsl_to_rgb(hsl: vec3) -> vec3 { ); } +// The HDR pipeline feeds this shader LINEAR light, but the HSL model (and the +// lightness/saturation axes users expect) is defined on gamma-encoded sRGB. +// Convert to sRGB, run the HSL adjustment there, then convert back to linear. +// sRGB helpers (linear_to_srgb / srgb_to_linear) come from the prepended +// COLOR_WGSL prelude. + @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { let src = textureSample(source_tex, source_sampler, in.uv); @@ -91,8 +97,10 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { let saturation = uniforms.params0.y; // Multiplier (1.0 = no change) let lightness = uniforms.params0.z; // Additive (-1 to 1) + let src_srgb = linear_to_srgb(src.rgb); + // Convert to HSL - var hsl = rgb_to_hsl(src.rgb); + var hsl = rgb_to_hsl(src_srgb); // Apply adjustments hsl.x = fract(hsl.x + hue_shift); // Shift hue (wrapping) @@ -102,6 +110,6 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { // Convert back to RGB let adjusted = hsl_to_rgb(hsl); - let result = mix(src.rgb, adjusted, uniforms.mix); - return vec4(result, src.a); + let result_srgb = mix(src_srgb, adjusted, uniforms.mix); + return vec4(srgb_to_linear(result_srgb), src.a); } diff --git a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_invert.wgsl b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_invert.wgsl index 8bb5b28..c396eaf 100644 --- a/lightningbeam-ui/lightningbeam-core/src/shaders/effect_invert.wgsl +++ b/lightningbeam-ui/lightningbeam-core/src/shaders/effect_invert.wgsl @@ -33,13 +33,19 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { return out; } +// The HDR pipeline feeds these shaders LINEAR light, but "invert" is a +// perceptual operation defined in gamma/display space (Photoshop, GIMP, etc.). +// Convert to sRGB, invert there, then convert back to linear. The sRGB helpers +// (linear_to_srgb / srgb_to_linear) come from the prepended COLOR_WGSL prelude. + @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { let src = textureSample(source_tex, source_sampler, in.uv); let amount = uniforms.params0.x; // params[0] - let inverted = vec3(1.0) - src.rgb; - let result = mix(src.rgb, inverted, amount * uniforms.mix); + let src_srgb = linear_to_srgb(src.rgb); + let inverted = vec3(1.0) - src_srgb; + let result_srgb = mix(src_srgb, inverted, amount * uniforms.mix); - return vec4(result, src.a); + return vec4(srgb_to_linear(result_srgb), src.a); } diff --git a/lightningbeam-ui/lightningbeam-core/src/shape.rs b/lightningbeam-ui/lightningbeam-core/src/shape.rs index 84d7851..cd08af6 100644 --- a/lightningbeam-ui/lightningbeam-core/src/shape.rs +++ b/lightningbeam-ui/lightningbeam-core/src/shape.rs @@ -395,7 +395,7 @@ mod tests { let shape = Shape::new(path); assert_eq!(shape.versions.len(), 1); - assert!(shape.fill_color.is_some()); + assert!(shape.fill_color.is_none()); } #[test] diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs index 5478004..627cc72 100644 --- a/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs @@ -417,6 +417,152 @@ impl VectorGraph { self.boundary_to_bezpath(&fill.boundary) } + /// Interpolate toward `other` by `t` ∈ [0,1] for a same-topology shape tween. + /// + /// Returns `None` if the two graphs don't share identical topology — same vertex, + /// edge and fill structure (counts, deleted flags, edge endpoints, fill boundaries). + /// In that case the caller should hold the source keyframe instead of morphing. + /// Vertex positions, edge curves, stroke widths and stroke/fill colours are lerped. + pub fn interpolated(&self, other: &VectorGraph, t: f64) -> Option { + if self.vertices.len() != other.vertices.len() + || self.edges.len() != other.edges.len() + || self.fills.len() != other.fills.len() + { + return None; + } + for (a, b) in self.vertices.iter().zip(&other.vertices) { + if a.deleted != b.deleted { + return None; + } + } + for (a, b) in self.edges.iter().zip(&other.edges) { + if a.deleted != b.deleted || a.vertices != b.vertices { + return None; + } + } + for (a, b) in self.fills.iter().zip(&other.fills) { + if a.deleted != b.deleted || a.boundary != b.boundary { + return None; + } + } + + let lf = |x: f64, y: f64| x + (y - x) * t; + let lp = |p: Point, q: Point| Point::new(lf(p.x, q.x), lf(p.y, q.y)); + let lc = |a: Option, b: Option| match (a, b) { + (Some(a), Some(b)) => { + let c = |x: u8, y: u8| (lf(x as f64, y as f64)).round().clamp(0.0, 255.0) as u8; + Some(ShapeColor::new(c(a.r, b.r), c(a.g, b.g), c(a.b, b.b), c(a.a, b.a))) + } + (a, _) => a, + }; + + let mut g = self.clone(); + for (i, v) in g.vertices.iter_mut().enumerate() { + v.position = lp(self.vertices[i].position, other.vertices[i].position); + } + for (i, e) in g.edges.iter_mut().enumerate() { + let (a, b) = (self.edges[i].curve, other.edges[i].curve); + e.curve = CubicBez::new(lp(a.p0, b.p0), lp(a.p1, b.p1), lp(a.p2, b.p2), lp(a.p3, b.p3)); + if let (Some(s), Some(sa), Some(sb)) = ( + e.stroke_style.as_mut(), + self.edges[i].stroke_style.as_ref(), + other.edges[i].stroke_style.as_ref(), + ) { + s.width = lf(sa.width, sb.width); + } + e.stroke_color = lc(self.edges[i].stroke_color, other.edges[i].stroke_color); + } + for (i, f) in g.fills.iter_mut().enumerate() { + f.color = lc(self.fills[i].color, other.fills[i].color); + } + Some(g) + } + + /// A point guaranteed to lie inside the fill — for point-in-region classification + /// (e.g. deciding whether a fill is inside a lasso). Prefers the polygon area-centroid, + /// but for a non-convex fill (e.g. an L-shape, where the area-centroid can fall in the + /// concavity *outside* the shape) it steps just inward from a boundary edge instead. + /// The naive average of boundary-edge midpoints is NOT reliable here — it can land + /// outside a non-convex fill and misclassify it. + pub fn fill_interior_point(&self, fill_id: FillId) -> Point { + let boundary = self.fills[fill_id.idx()].boundary.clone(); + self.boundary_interior_point(&boundary) + } + + /// A point guaranteed to lie inside the region enclosed by a `(edge, direction)` + /// boundary loop. See [`fill_interior_point`]. + pub fn boundary_interior_point(&self, boundary: &[(EdgeId, Direction)]) -> Point { + use kurbo::{ParamCurve, Shape, Vec2}; + let path = self.boundary_to_bezpath(boundary); + + // Ordered polygon corners: the directed start point of each boundary edge. + let mut pts: Vec = Vec::new(); + for &(eid, dir) in boundary { + if eid.is_none() { + continue; + } + let c = self.edges[eid.idx()].curve; + pts.push(match dir { + Direction::Forward => c.p0, + Direction::Backward => c.p3, + }); + } + if pts.len() < 3 { + if pts.is_empty() { + return Point::ZERO; + } + let (sx, sy) = pts.iter().fold((0.0, 0.0), |(x, y), p| (x + p.x, y + p.y)); + return Point::new(sx / pts.len() as f64, sy / pts.len() as f64); + } + + // Shoelace area-centroid. + let (mut a2, mut cx, mut cy) = (0.0, 0.0, 0.0); + for i in 0..pts.len() { + let p0 = pts[i]; + let p1 = pts[(i + 1) % pts.len()]; + let cross = p0.x * p1.y - p1.x * p0.y; + a2 += cross; + cx += (p0.x + p1.x) * cross; + cy += (p0.y + p1.y) * cross; + } + if a2.abs() > 1e-9 { + let c = Point::new(cx / (3.0 * a2), cy / (3.0 * a2)); + if path.winding(c) != 0 { + return c; + } + } + + // Fallback: step a small distance inward from a boundary edge midpoint. + let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for p in &pts { + minx = minx.min(p.x); + miny = miny.min(p.y); + maxx = maxx.max(p.x); + maxy = maxy.max(p.y); + } + let eps = ((maxx - minx).min(maxy - miny) * 1e-3).max(1e-4); + for &(eid, _) in boundary { + if eid.is_none() { + continue; + } + let c = self.edges[eid.idx()].curve; + let mid = c.eval(0.5); + let tangent = c.eval(0.5001) - c.eval(0.4999); + let len = tangent.hypot(); + if len < 1e-12 { + continue; + } + let n = Vec2::new(-tangent.y / len, tangent.x / len); + for s in [1.0_f64, -1.0] { + let cand = mid + n * (s * eps); + if path.winding(cand) != 0 { + return cand; + } + } + } + pts[0] + } + // ------------------------------------------------------------------- // Vertex editing // ------------------------------------------------------------------- @@ -653,6 +799,10 @@ impl VectorGraph { } let mut all_new_edges = Vec::new(); + // Sub-edges produced when a stroke segment splits an existing edge (incl. an earlier + // segment of this same stroke). Tracked separately so they reach the fill re-tracer + // without polluting the returned edge list. + let mut split_products: Vec = Vec::new(); let mut prev_end_vertex: Option = None; for (seg_idx, seg) in expanded_segments.iter().enumerate() { @@ -706,7 +856,14 @@ impl VectorGraph { let remapped_t = original_edge_t / head_end; let remapped_t = remapped_t.clamp(ENDPOINT_T_MARGIN, 1.0 - ENDPOINT_T_MARGIN); - let (mid_v, _sub_a, _sub_b) = self.split_edge(eid, remapped_t); + let (mid_v, sub_a, sub_b) = self.split_edge(eid, remapped_t); + // Track the split products so the fill re-tracer sees them: when a later + // stroke segment crosses an earlier one, these sub-edges are part of the + // stroke's arrangement but would otherwise be invisible to the re-trace. + // (Kept out of `all_new_edges` so the returned edge list stays the stroke's + // own edges only.) + split_products.push(sub_a); + split_products.push(sub_b); // Snap vertex to intersection point self.vertices[mid_v.idx()].position = point; // Merge with nearby existing vertex if within snap distance @@ -791,127 +948,511 @@ impl VectorGraph { prev_end_vertex = Some(end_v); } - // Fill splitting pass: for each new edge, check if both endpoints - // lie on any fill's boundary — if so, split that fill. - let edges_to_check = all_new_edges.clone(); - for &eid in &edges_to_check { - let v0 = self.edges[eid.idx()].vertices[0]; - let v1 = self.edges[eid.idx()].vertices[1]; + // Weld dangling stroke endpoints onto a near-coincident existing vertex. A + // self-intersecting freehand stroke can create an intersection vertex a fraction of + // a pixel away from a segment endpoint that should be the same point; if they don't + // merge, the stroke's loop is broken by a degree-1 stub and the cut is lost. + self.weld_dangling_endpoints(&all_new_edges, snap_epsilon.max(1.0)); - // Find fills where both v0 and v1 appear as boundary vertices - let fill_ids: Vec = self.fills - .iter() - .enumerate() - .filter(|(_, f)| !f.deleted) - .filter(|(_, f)| { - let has_v0 = f.boundary.iter().any(|&(be, _)| { - let e = &self.edges[be.idx()]; - e.vertices[0] == v0 || e.vertices[1] == v0 - }); - let has_v1 = f.boundary.iter().any(|&(be, _)| { - let e = &self.edges[be.idx()]; - e.vertices[0] == v1 || e.vertices[1] == v1 - }); - has_v0 && has_v1 - }) - .map(|(i, _)| FillId(i as u32)) - .collect(); + // Coincident-edge cleanup: a new edge that lands exactly on an existing edge + // between the same two vertices (e.g. drawing a shape whose edge snaps onto an + // existing one) must not be duplicated — duplicates produce zero-area "sliver" + // fills. Merge such duplicates before splitting/filling. + self.dedupe_coincident_new_edges(&all_new_edges); - for fid in fill_ids { - self.split_fill_by_edge(fid, eid); - } - } + // Re-derive the fills touched by the new edges. Rather than incrementally splitting + // along single cut edges (which can't handle a lasso whose path is interrupted by a + // hole/notch in a non-convex fill), we re-trace the planar faces of the affected + // sub-arrangement and rebuild the fills from them. This is robust for arbitrary + // holed/concave fills (e.g. cutting across geometry left behind by a prior group). + // Include split products so a self-crossing stroke's full arrangement is seen. + let mut retrace_edges = all_new_edges.clone(); + retrace_edges.extend(split_products); + self.retrace_fills_after_cut(&retrace_edges); + + // Drop any zero-area fills (e.g. slivers left between coincident edges). + self.remove_degenerate_fills(); all_new_edges } - /// When a new edge splits a fill (both endpoints on the fill's boundary), - /// split the fill into two fills. - pub fn split_fill_by_edge( - &mut self, - fill_id: FillId, - splitting_edge: EdgeId, - ) -> Option<(FillId, FillId)> { - let fill = &self.fills[fill_id.idx()]; - if fill.deleted { - return None; + /// Merge edges from the latest stroke that are geometrically coincident with an + /// existing edge between the same two vertices (drawing a shape whose edge lands exactly + /// on an existing edge). Keeps the existing edge, redirects fill references, and frees + /// the duplicate — preventing zero-area "sliver" fills between the two copies. + /// Weld degree-1 endpoints of freshly inserted edges onto a near-coincident existing + /// vertex. A self-intersecting freehand stroke can create an intersection vertex a + /// fraction of a pixel from a segment endpoint that should be the same point; if they + /// don't merge, the stroke's loop is broken by a degree-1 stub and the cut is lost. + fn weld_dangling_endpoints(&mut self, new_edges: &[EdgeId], eps: f64) { + // Repeatedly weld the closest dangling new endpoint to its nearest neighbour. + loop { + let mut to_merge: Option<(VertexId, VertexId)> = None; // (keep, merge) + 'scan: for &e in new_edges { + if self.edges[e.idx()].deleted { + continue; + } + for &v in &self.edges[e.idx()].vertices { + if self.vertices[v.idx()].deleted || self.edges_at_vertex(v).len() != 1 { + continue; + } + let pv = self.vertices[v.idx()].position; + let mut best: Option<(f64, VertexId)> = None; + for ui in 0..self.vertices.len() { + if ui == v.idx() || self.vertices[ui].deleted { + continue; + } + let pu = self.vertices[ui].position; + let d = (pu.x - pv.x).hypot(pu.y - pv.y); + if d < eps && best.map_or(true, |(bd, _)| d < bd) { + best = Some((d, VertexId(ui as u32))); + } + } + if let Some((_, keep)) = best { + to_merge = Some((keep, v)); + break 'scan; + } + } + } + match to_merge { + Some((keep, merge)) => self.merge_vertices(keep, merge), + None => break, + } + } + // Drop any edges that collapsed to zero length (both endpoints welded together). + let degenerate: Vec = self + .edges + .iter() + .enumerate() + .filter(|(_, e)| !e.deleted && e.vertices[0] == e.vertices[1]) + .map(|(i, _)| EdgeId(i as u32)) + .collect(); + for e in degenerate { + for fill in &mut self.fills { + if !fill.deleted { + fill.boundary.retain(|&(fe, _)| fe != e); + } + } + self.free_edge(e); + } + } + + fn dedupe_coincident_new_edges(&mut self, new_edges: &[EdgeId]) { + for &ne in new_edges { + if self.edges[ne.idx()].deleted { + continue; + } + let va = self.edges[ne.idx()].vertices[0]; + let vb = self.edges[ne.idx()].vertices[1]; + let candidates: Vec = self + .edges_at_vertex(va) + .into_iter() + .filter(|&e| e != ne && !self.edges[e.idx()].deleted) + .filter(|&e| { + let v = self.edges[e.idx()].vertices; + (v[0] == va && v[1] == vb) || (v[0] == vb && v[1] == va) + }) + .collect(); + for c in candidates { + if self.edges[c.idx()].deleted { + continue; + } + if self.curves_coincident(ne, c) { + self.redirect_edge_in_fills(ne, c); + self.free_edge(ne); + break; + } + } + } + } + + /// Whether two edges (already known to share both endpoints) trace the same path. + /// Coincident duplicates in practice are straight collinear segments (a shape edge + /// snapping onto an existing edge), so we treat "both are straight lines between the + /// same endpoints" as coincident. Comparing curve `eval(t)` directly is unreliable — + /// split sub-edges are non-uniformly parameterised, so equal `t` ≠ equal point. + fn curves_coincident(&self, a: EdgeId, b: EdgeId) -> bool { + let is_straight = |c: kurbo::CubicBez| { + let chord = c.p3 - c.p0; + let len = chord.hypot(); + if len < 1e-9 { + return true; // zero-length chord — degenerate, treat as coincident + } + // Perpendicular distance of each control point from the p0→p3 chord. + let dist = |p: Point| ((p - c.p0).cross(chord)).abs() / len; + dist(c.p1) < 1e-2 && dist(c.p2) < 1e-2 + }; + is_straight(self.edges[a.idx()].curve) && is_straight(self.edges[b.idx()].curve) + } + + /// Replace every `from` boundary reference with `to`, preserving traversal direction. + fn redirect_edge_in_fills(&mut self, from: EdgeId, to: EdgeId) { + let f = self.edges[from.idx()].vertices; + let t = self.edges[to.idx()].vertices; + let same_dir = t[0] == f[0] && t[1] == f[1]; + for fill in &mut self.fills { + if fill.deleted { + continue; + } + for entry in &mut fill.boundary { + if entry.0 == from { + entry.1 = match (entry.1, same_dir) { + (Direction::Forward, true) | (Direction::Backward, false) => { + Direction::Forward + } + (Direction::Backward, true) | (Direction::Forward, false) => { + Direction::Backward + } + }; + entry.0 = to; + } + } + } + } + + /// Drop fills that enclose ~zero area (degenerate slivers from coincident edges). + fn remove_degenerate_fills(&mut self) { + for i in 0..self.fills.len() { + if self.fills[i].deleted { + continue; + } + let mut pts: Vec = Vec::new(); + for &(eid, dir) in &self.fills[i].boundary { + if eid.is_none() { + continue; + } + let c = self.edges[eid.idx()].curve; + pts.push(match dir { + Direction::Forward => c.p0, + Direction::Backward => c.p3, + }); + } + let area = if pts.len() < 3 { + 0.0 + } else { + let mut a2 = 0.0; + for k in 0..pts.len() { + let p0 = pts[k]; + let p1 = pts[(k + 1) % pts.len()]; + a2 += p0.x * p1.y - p1.x * p0.y; + } + (a2 * 0.5).abs() + }; + if area < 1e-6 { + self.fills[i].deleted = true; + self.free_fills.push(i as u32); + } + } + } + + /// Directed end vertex of a `(edge, direction)` boundary entry. + #[inline] + fn entry_end_vertex(&self, eid: EdgeId, dir: Direction) -> VertexId { + match dir { + Direction::Forward => self.edges[eid.idx()].vertices[1], + Direction::Backward => self.edges[eid.idx()].vertices[0], + } + } + + /// Re-derive the fills touched by a freshly inserted stroke by re-tracing the planar + /// faces of the affected sub-arrangement. This replaces incremental "split a fill by a + /// cut edge" logic, which can't handle a cut whose path is interrupted by a hole/notch + /// in a non-convex fill. Each affected fill is deleted and rebuilt from the traced + /// faces that lie inside it (inheriting its colour/rule); faces outside it — or in a + /// hole — are dropped. + fn retrace_fills_after_cut(&mut self, new_edges: &[EdgeId]) { + use kurbo::{ParamCurve, Shape}; + let new_set: HashSet = new_edges + .iter() + .filter(|&&e| !e.is_none() && !self.edges[e.idx()].deleted) + .copied() + .collect(); + if new_set.is_empty() { + return; + } + let new_verts: HashSet = + new_set.iter().flat_map(|&e| self.edges[e.idx()].vertices).collect(); + + // Affected fills: any non-deleted fill that shares a vertex with a new edge. + let affected: Vec = (0..self.fills.len()) + .filter(|&i| !self.fills[i].deleted) + .filter(|&i| { + self.fills[i].boundary.iter().any(|&(e, _)| { + !e.is_none() + && self.edges[e.idx()].vertices.iter().any(|v| new_verts.contains(v)) + }) + }) + .map(|i| FillId(i as u32)) + .collect(); + if affected.is_empty() { + return; } - let split_v0 = self.edges[splitting_edge.idx()].vertices[0]; - let split_v1 = self.edges[splitting_edge.idx()].vertices[1]; + // Snapshot each affected fill's path + attributes before we delete them. + let originals: Vec<(kurbo::BezPath, Option, FillRule)> = affected + .iter() + .map(|&f| { + ( + self.fill_to_bezpath(f), + self.fills[f.idx()].color, + self.fills[f.idx()].fill_rule, + ) + }) + .collect(); - // Find the positions in the boundary where the splitting edge's - // endpoint vertices appear as the "arrival" vertex of a directed edge. - let boundary = fill.boundary.clone(); + // Edge set for the local arrangement: every affected fill's boundary edges plus the + // ENTIRE inserted stroke. We include the whole stroke (not just the segments whose + // midpoint is inside a fill) because a wiggly freehand lasso has segments that dip + // just outside the fill; excluding them would break the inside-arc chain into + // dangling fragments that then get pruned away, losing the cut entirely. The stroke + // forms closed loops, so it contributes no dangling edges; faces that end up outside + // every affected fill are discarded by the classification below. + let mut edge_set: HashSet = HashSet::new(); + for &f in &affected { + for &(e, _) in &self.fills[f.idx()].boundary { + if !e.is_none() && !self.edges[e.idx()].deleted { + edge_set.insert(e); + } + } + } + edge_set.extend(new_set.iter().copied()); - // Helper: get the "end" vertex of a directed boundary edge - let end_vertex = |eid: EdgeId, dir: Direction| -> VertexId { - match dir { - Direction::Forward => self.edges[eid.idx()].vertices[1], - Direction::Backward => self.edges[eid.idx()].vertices[0], + // Expand to the induced subgraph on the covered vertices. A self-intersecting + // freehand stroke splits its own edges via `split_edge`, whose sub-edges aren't in + // `new_edges`; without them the local arrangement has gaps and the stroke's loop + // looks like dangling fragments. Adding every edge whose endpoints are both already + // covered closes those gaps (the sub-edges connect already-covered stroke vertices). + loop { + let verts: HashSet = edge_set + .iter() + .flat_map(|&e| self.edges[e.idx()].vertices) + .collect(); + let added: Vec = (0..self.edges.len()) + .map(|i| EdgeId(i as u32)) + .filter(|&e| !self.edges[e.idx()].deleted && !edge_set.contains(&e)) + .filter(|&e| { + let [a, b] = self.edges[e.idx()].vertices; + verts.contains(&a) && verts.contains(&b) + }) + .collect(); + if added.is_empty() { + break; + } + edge_set.extend(added); + } + + // Prune dangling edges (a vertex with degree < 2 in the local arrangement). They + // form spikes, never real face boundaries — a freehand lasso that wiggles or nearly + // self-touches leaves such stubs, and tracing them produces a face that runs out and + // back along the same edge (a degenerate self-touching boundary). Iterate, since + // removing one stub can expose another. + loop { + let mut degree: HashMap = HashMap::new(); + for &e in &edge_set { + for &v in &self.edges[e.idx()].vertices { + *degree.entry(v).or_default() += 1; + } + } + let dangling: Vec = edge_set + .iter() + .copied() + .filter(|&e| { + let [a, b] = self.edges[e.idx()].vertices; + a == b || degree[&a] < 2 || degree[&b] < 2 + }) + .collect(); + if dangling.is_empty() { + break; + } + for e in dangling { + edge_set.remove(&e); + } + } + + let faces = self.trace_faces(&edge_set); + + // Replace the affected fills with the re-traced bounded faces that fall inside them. + for &f in &affected { + self.fills[f.idx()].deleted = true; + self.free_fills.push(f.0); + } + for mut face in faces { + // Collapse degenerate "spikes" — a sequence that runs out to a point and back + // (e.g. across near-coincident duplicate tiny edges from a dense freehand path). + self.collapse_boundary_spikes(&mut face); + if face.len() < 3 { + continue; + } + // Only bounded (counter-clockwise, positive-area) faces are real regions; the + // outer face is clockwise/negative. + if self.face_signed_area(&face) <= 1e-6 { + continue; + } + let sample = self.boundary_interior_point(&face); + if let Some((_, color, rule)) = + originals.iter().find(|(p, _, _)| p.winding(sample) != 0) + { + self.alloc_fill(face, *color, *rule); + } + } + } + + /// Remove out-and-back "spikes" from a face boundary: consecutive entries where the + /// second exactly reverses the first (the boundary returns to where it started, e.g. + /// bouncing across near-coincident duplicate edges). These are zero-area and would make + /// `boundary_to_bezpath` render a stray hair; collapsing them yields a simple loop. + fn collapse_boundary_spikes(&self, face: &mut Vec<(EdgeId, Direction)>) { + let dstart = |entry: &(EdgeId, Direction)| -> Point { + let c = self.edges[entry.0.idx()].curve; + match entry.1 { + Direction::Forward => c.p0, + Direction::Backward => c.p3, } }; - - // Find positions where boundary edges arrive at split_v0 and split_v1 - let mut pos_v0: Option = None; - let mut pos_v1: Option = None; - - for (i, &(eid, dir)) in boundary.iter().enumerate() { - let ev = end_vertex(eid, dir); - if ev == split_v0 && pos_v0.is_none() { - pos_v0 = Some(i); + let dend = |entry: &(EdgeId, Direction)| -> Point { + let c = self.edges[entry.0.idx()].curve; + match entry.1 { + Direction::Forward => c.p3, + Direction::Backward => c.p0, } - if ev == split_v1 && pos_v1.is_none() { - pos_v1 = Some(i); - } - } - - let pos_v0 = pos_v0?; - let pos_v1 = pos_v1?; - - // Ensure we have two distinct positions - if pos_v0 == pos_v1 { - return None; - } - - // Walk boundary in two halves: - // Half A: from pos_v0+1 to pos_v1 (inclusive), then splitting_edge Forward - // Half B: from pos_v1+1 to pos_v0 (wrapping), then splitting_edge Backward - let n = boundary.len(); - let color = fill.color; - let fill_rule = fill.fill_rule; - - let mut half_a = Vec::new(); - let mut idx = (pos_v0 + 1) % n; + }; + const EPS: f64 = 0.5; loop { - half_a.push(boundary[idx]); - if idx == pos_v1 { + let n = face.len(); + if n < 2 { break; } - idx = (idx + 1) % n; - } - half_a.push((splitting_edge, Direction::Forward)); - - let mut half_b = Vec::new(); - idx = (pos_v1 + 1) % n; - loop { - half_b.push(boundary[idx]); - if idx == pos_v0 { + let mut collapsed = false; + for i in 0..n { + let j = (i + 1) % n; + // entries i and j cancel when j ends back at i's start. + let si = dstart(&face[i]); + let ej = dend(&face[j]); + if (si.x - ej.x).hypot(si.y - ej.y) < EPS { + let (hi, lo) = if i > j { (i, j) } else { (j, i) }; + face.remove(hi); + face.remove(lo); + collapsed = true; + break; + } + } + if !collapsed { break; } - idx = (idx + 1) % n; } - half_b.push((splitting_edge, Direction::Backward)); + } - // Delete the original fill - self.fills[fill_id.idx()].deleted = true; - self.free_fills.push(fill_id.0); + /// Trace all faces of the planar arrangement formed by `edge_set`, using the standard + /// angular next-edge rule (turn to the clockwise-adjacent dart of the twin at each + /// vertex). Returns each face as an ordered `(edge, direction)` loop. Bounded faces + /// come out counter-clockwise (positive signed area); the outer face clockwise. + fn trace_faces(&self, edge_set: &HashSet) -> Vec> { + // Outgoing darts per vertex, sorted by outgoing angle (CCW). + let mut out: HashMap> = HashMap::new(); + for &e in edge_set { + if self.edges[e.idx()].deleted { + continue; + } + let [a, b] = self.edges[e.idx()].vertices; + out.entry(a) + .or_default() + .push((self.dart_angle(e, Direction::Forward), (e, Direction::Forward))); + out.entry(b) + .or_default() + .push((self.dart_angle(e, Direction::Backward), (e, Direction::Backward))); + } + for darts in out.values_mut() { + darts.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal)); + } - // Create two new fills - let fill_a = self.alloc_fill(half_a, color, fill_rule); - let fill_b = self.alloc_fill(half_b, color, fill_rule); + let mut visited: HashSet<(EdgeId, Direction)> = HashSet::new(); + let mut faces: Vec> = Vec::new(); + let cap = edge_set.len() * 2 + 4; + for &e in edge_set { + if self.edges[e.idx()].deleted { + continue; + } + for dir in [Direction::Forward, Direction::Backward] { + let start = (e, dir); + if visited.contains(&start) { + continue; + } + let mut face: Vec<(EdgeId, Direction)> = Vec::new(); + let mut d = start; + loop { + visited.insert(d); + face.push(d); + // Next dart: at the end vertex, the dart clockwise-adjacent to the twin. + let end_v = self.entry_end_vertex(d.0, d.1); + let twin = ( + d.0, + match d.1 { + Direction::Forward => Direction::Backward, + Direction::Backward => Direction::Forward, + }, + ); + let darts = match out.get(&end_v) { + Some(d) => d, + None => break, + }; + let Some(idx) = darts.iter().position(|&(_, dd)| dd == twin) else { + break; + }; + let next = darts[(idx + darts.len() - 1) % darts.len()].1; + if next == start { + break; + } + if visited.contains(&next) || face.len() > cap { + break; + } + d = next; + } + if face.len() >= 3 { + faces.push(face); + } + } + } + faces + } - Some((fill_a, fill_b)) + /// Outgoing direction angle of a dart at its start vertex. + fn dart_angle(&self, e: EdgeId, dir: Direction) -> f64 { + let c = self.edges[e.idx()].curve; + let (base, cands) = match dir { + Direction::Forward => (c.p0, [c.p1, c.p2, c.p3]), + Direction::Backward => (c.p3, [c.p2, c.p1, c.p0]), + }; + for cand in cands { + let d = cand - base; + if d.hypot() > 1e-9 { + return d.y.atan2(d.x); + } + } + 0.0 + } + + /// Signed area of a face given as an ordered `(edge, direction)` loop (CCW positive). + fn face_signed_area(&self, face: &[(EdgeId, Direction)]) -> f64 { + let pts: Vec = face + .iter() + .map(|&(e, dir)| { + let c = self.edges[e.idx()].curve; + match dir { + Direction::Forward => c.p0, + Direction::Backward => c.p3, + } + }) + .collect(); + if pts.len() < 3 { + return 0.0; + } + let mut a2 = 0.0; + for i in 0..pts.len() { + let p0 = pts[i]; + let p1 = pts[(i + 1) % pts.len()]; + a2 += p0.x * p1.y - p1.x * p0.y; + } + a2 * 0.5 } /// Merge two fills that share a boundary edge (e.g., after edge deletion). @@ -1458,11 +1999,14 @@ impl VectorGraph { // ── Region selection: extract / merge subgraph ────────────────────── - /// Extract a subgraph containing `inside_edges` and `inside_fills`. + /// Extract a subgraph containing `inside_edges` and `inside_fills` (typically a + /// geometry selection — `select_fill` already includes each fill's boundary edges). /// - /// Boundary edges (`boundary_edge_ids`) are **duplicated** — they exist in - /// both the returned graph and `self`, so both sides have closed fill - /// boundaries when the selection is moved. + /// **Boundary edges** are *duplicated* (copied into the returned graph but kept in + /// `self`, so remaining shapes keep closed boundaries). They are `explicit_boundary` + /// (a cut the caller knows about, e.g. a lasso region — pass an empty set if none) + /// UNION any inside edge still shared with a non-extracted fill (derived here, so a + /// plain geometry selection needs no boundary analysis from the caller). /// /// Returns `(new_graph, vertex_map, edge_map)` where the maps go from /// old (self) IDs to new (returned graph) IDs. @@ -1470,17 +2014,58 @@ impl VectorGraph { &mut self, inside_edges: &HashSet, inside_fills: &HashSet, - boundary_edge_ids: &HashSet, + explicit_boundary: &HashSet, ) -> (VectorGraph, HashMap, HashMap) { let mut new_graph = VectorGraph::new(); let mut vtx_map: HashMap = HashMap::new(); let mut edge_map: HashMap = HashMap::new(); - // Collect all edge IDs we need to copy into the new graph - let edges_to_copy: HashSet = inside_edges - .union(boundary_edge_ids) - .copied() - .collect(); + // Augment the inside set with every boundary edge of an extracted fill. A + // selection might not enumerate them all (e.g. lasso/region selection populates + // edges differently than `select_fill`); without this an extracted fill would be + // copied with `EdgeId::NONE` standing in for a missing edge, and that NONE later + // panics any code that indexes `fill.boundary` (e.g. `insert_stroke`). + let inside_edges: HashSet = { + let mut s = inside_edges.clone(); + for &fid in inside_fills { + if fid.is_none() { + continue; + } + if let Some(fill) = self.fills.get(fid.idx()) { + if fill.deleted { + continue; + } + for &(eid, _) in &fill.boundary { + if !eid.is_none() { + s.insert(eid); + } + } + } + } + s + }; + let inside_edges = &inside_edges; + + // Boundary = `explicit_boundary` (e.g. a region/lasso cut the caller knows about) + // UNION any inside edge still referenced by a fill we're NOT extracting (a shared + // DCEL edge — must be duplicated, not moved, or that fill dangles). Deriving the + // latter here means a plain geometry selection needs no boundary analysis. + let mut boundary_edge_ids: HashSet = explicit_boundary.clone(); + for (i, fill) in self.fills.iter().enumerate() { + if fill.deleted || inside_fills.contains(&FillId(i as u32)) { + continue; + } + for &(eid, _) in &fill.boundary { + if !eid.is_none() && inside_edges.contains(&eid) { + boundary_edge_ids.insert(eid); + } + } + } + let boundary_edge_ids = &boundary_edge_ids; + + // Copy all inside edges + any boundary edges (the explicit ones may not be in + // inside_edges); boundary edges are kept in self below. + let edges_to_copy: HashSet = inside_edges.union(boundary_edge_ids).copied().collect(); // Collect all vertices referenced by edges we're copying let mut referenced_vids: HashSet = HashSet::new(); @@ -1493,16 +2078,21 @@ impl VectorGraph { } } - // Determine which vertices are interior (exclusively owned by the - // extracted subgraph) vs boundary (shared with remaining geometry). - // A vertex is interior if ALL of its incident edges are either in - // inside_edges or boundary_edge_ids. + // Determine which vertices are safe to free from `self`. A vertex can only be + // freed if EVERY one of its incident edges is actually being removed from `self`, + // i.e. is an inside edge that is NOT a boundary edge. Boundary edges are kept + // (duplicated) in `self`, so a vertex touching one is still referenced and must + // remain — otherwise it becomes a freed-but-referenced vertex whose slot a later + // `alloc_vertex` reuses, corrupting the remaining fill. let mut interior_vertices: HashSet = HashSet::new(); let mut boundary_vertices: HashSet = HashSet::new(); for &vid in &referenced_vids { let incident = self.edges_at_vertex(vid); - let all_inside = incident.iter().all(|&eid| edges_to_copy.contains(&eid)); - if all_inside { + let all_removed = !incident.is_empty() + && incident + .iter() + .all(|&eid| inside_edges.contains(&eid) && !boundary_edge_ids.contains(&eid)); + if all_removed { interior_vertices.insert(vid); } else { boundary_vertices.insert(vid); @@ -1566,9 +2156,10 @@ impl VectorGraph { new_graph.fills[new_fid.idx()].image_fill = fill.image_fill; } - // Remove inside_edges from self (but NOT boundary edges — those are duplicated) + // Remove inside_edges from self, EXCEPT boundary edges (those are duplicated — + // a non-extracted fill still needs them). for &eid in inside_edges { - if !eid.is_none() && !self.edges[eid.idx()].deleted { + if !eid.is_none() && !boundary_edge_ids.contains(&eid) && !self.edges[eid.idx()].deleted { self.free_edge(eid); } } diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/mod.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/mod.rs index 742aa64..9bb1b34 100644 --- a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/mod.rs @@ -10,3 +10,7 @@ mod editing; mod gap_close; #[cfg(test)] mod region; +#[cfg(test)] +mod region_cut_select; +#[cfg(test)] +mod tween; diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_cut_select.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_cut_select.rs new file mode 100644 index 0000000..6e80f1e --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_cut_select.rs @@ -0,0 +1,660 @@ +//! Reproduces the unified region-select behaviour at the graph level: cutting a shape +//! along a lasso outline (`insert_stroke`) and classifying which resulting fills/edges +//! fall inside the lasso — the same logic the editor's `execute_region_select` runs. +//! +//! The shape is built exactly as the editor builds a rectangle: `insert_stroke` of a +//! closed rect loop, then `paint_bucket` to create the fill (NOT a hand-rolled +//! `alloc_fill`), because the bug is in how that traced fill's boundary survives being +//! split by the lasso cut. + +use super::super::*; +use kurbo::{BezPath, CubicBez, ParamCurve, Point, Shape}; + +/// Straight-line cubic from a to b. +fn line(a: Point, b: Point) -> CubicBez { + CubicBez::new( + a, + Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0), + Point::new(a.x + 2.0 * (b.x - a.x) / 3.0, a.y + 2.0 * (b.y - a.y) / 3.0), + b, + ) +} + +fn closed_rect_path(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> BezPath { + let mut p = BezPath::new(); + p.move_to((min_x, min_y)); + p.line_to((max_x, min_y)); + p.line_to((max_x, max_y)); + p.line_to((min_x, max_y)); + p.close_path(); + p +} + +/// Draw a filled rectangle into an existing graph the way the editor's rectangle tool does. +fn draw_filled_rect(g: &mut VectorGraph, min_x: f64, min_y: f64, max_x: f64, max_y: f64) { + let style = StrokeStyle { width: 1.0, ..Default::default() }; + let color = ShapeColor::rgb(0, 0, 0); + for segs in bezpath_to_cubic_segments(&closed_rect_path(min_x, min_y, max_x, max_y)) { + g.insert_stroke(&segs, Some(style.clone()), Some(color), 0.5); + } + let centroid = Point::new((min_x + max_x) / 2.0, (min_y + max_y) / 2.0); + g.paint_bucket(centroid, ShapeColor::rgb(255, 0, 0), FillRule::NonZero, 0.0) + .expect("paint_bucket should create a fill"); +} + +/// Build a filled rectangle the way the editor's rectangle tool does. +fn editor_filled_rect(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> VectorGraph { + let mut g = VectorGraph::new(); + draw_filled_rect(&mut g, min_x, min_y, max_x, max_y); + g +} + +/// Closed rectangular lasso: cubic segments (for insert_stroke) + BezPath (for winding). +fn rect_lasso(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> (Vec, BezPath) { + let p = [ + Point::new(min_x, min_y), + Point::new(max_x, min_y), + Point::new(max_x, max_y), + Point::new(min_x, max_y), + ]; + let segs = vec![line(p[0], p[1]), line(p[1], p[2]), line(p[2], p[3]), line(p[3], p[0])]; + let mut path = BezPath::new(); + path.move_to(p[0]); + for pt in &p[1..] { + path.line_to(*pt); + } + path.close_path(); + (segs, path) +} + +// Classify-against-lasso uses the same guaranteed-interior probe point the editor uses. +fn fill_centroid(g: &VectorGraph, fid: FillId) -> Point { + g.fill_interior_point(fid) +} + +/// Directed start/end points of a boundary entry. +fn dir_start(g: &VectorGraph, eid: EdgeId, dir: Direction) -> Point { + let c = g.edge(eid).curve; + match dir { + Direction::Forward => c.p0, + Direction::Backward => c.p3, + } +} +fn dir_end(g: &VectorGraph, eid: EdgeId, dir: Direction) -> Point { + let c = g.edge(eid).curve; + match dir { + Direction::Forward => c.p3, + Direction::Backward => c.p0, + } +} + +/// Describe a fill's boundary as an ordered list of (start → end) segments for diagnostics. +fn describe_boundary(g: &VectorGraph, fid: FillId) -> Vec<(EdgeId, Point, Point)> { + g.fill(fid) + .boundary + .iter() + .filter(|(e, _)| !e.is_none()) + .map(|&(e, d)| (e, dir_start(g, e, d), dir_end(g, e, d))) + .collect() +} + +/// Assert no fill boundary uses the same edge twice (the signature of a degenerate +/// "spike" where the trace runs out and back along a dangling edge). +fn assert_no_spikes(g: &VectorGraph) { + for (fi, f) in g.fills.iter().enumerate() { + if f.deleted { continue; } + let mut seen = std::collections::HashSet::new(); + for &(e, _) in &f.boundary { + if e.is_none() { continue; } + assert!(seen.insert(e.0), "fill {fi} uses edge {} twice (spike)", e.0); + } + } +} + +/// Assert every non-deleted fill's boundary is a connected closed loop. +fn assert_all_fills_connected(g: &VectorGraph) { + for (i, f) in g.fills.iter().enumerate() { + if f.deleted { + continue; + } + let fid = FillId(i as u32); + let b = describe_boundary(g, fid); + if b.is_empty() { + continue; + } + let n = b.len(); + for k in 0..n { + let (_, _, end) = b[k]; + let (_, next_start, _) = b[(k + 1) % n]; + // Tolerance well below any real gap (pixels) but above accumulated float drift. + assert!( + (end.x - next_start.x).abs() < 1e-2 && (end.y - next_start.y).abs() < 1e-2, + "fill {i} boundary disconnected between edge {k} (ends {end:?}) and \ + edge {} (starts {next_start:?}); boundary = {b:#?}", + (k + 1) % n + ); + } + } +} + +#[test] +fn single_vertical_cut_produces_two_connected_fills() { + // Rectangle (0,0)-(200,100), one vertical cut at x=100. + let mut g = editor_filled_rect(0.0, 0.0, 200.0, 100.0); + let cut = vec![line(Point::new(100.0, -10.0), Point::new(100.0, 110.0))]; + g.insert_stroke(&cut, None, None, 1.0); + + let live = g.fills.iter().filter(|f| !f.deleted).count(); + assert_eq!(live, 2, "one cut should split the rect into 2 fills, got {live}"); + assert_all_fills_connected(&g); +} + +#[test] +fn lasso_over_second_of_two_rects_splits_that_rect() { + // Two separate rectangles; lasso a vertical strip over the SECOND one. + let mut g = editor_filled_rect(0.0, 0.0, 200.0, 100.0); + draw_filled_rect(&mut g, 0.0, 150.0, 200.0, 250.0); + assert_eq!(g.fills.iter().filter(|f| !f.deleted).count(), 2, "two rects → two fills"); + + let (segs, lasso_path) = rect_lasso(50.0, 120.0, 150.0, 300.0); // crosses rect B only + g.insert_stroke(&segs, None, None, 1.0); + g.gc_invisible_edges(); + + // Rect B should now be split into 3 fills (left/middle/right strips); rect A is untouched. + // So 1 (rect A) + 3 (rect B pieces) = 4 fills total. + let live = g.fills.iter().filter(|f| !f.deleted).count(); + let inside_fills: Vec = g + .fills + .iter() + .enumerate() + .filter(|(_, f)| !f.deleted) + .map(|(i, _)| FillId(i as u32)) + .filter(|&fid| lasso_path.winding(fill_centroid(&g, fid)) != 0) + .collect(); + + let dump = || { + g.fills + .iter() + .enumerate() + .filter(|(_, f)| !f.deleted) + .map(|(i, _)| (i, fill_centroid(&g, FillId(i as u32)))) + .collect::>() + }; + + assert_eq!(live, 4, "rect B should split into 3 (total 4 fills); got {live}: {:#?}", dump()); + assert_eq!( + inside_fills.len(), + 1, + "exactly the center strip of rect B should be inside; got {:#?}", + dump() + ); + assert_all_fills_connected(&g); +} + +#[test] +fn lasso_strip_through_side_by_side_second_rect() { + // Two rectangles side by side; lasso a vertical strip through the SECOND (right) one. + let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0); + draw_filled_rect(&mut g, 150.0, 0.0, 250.0, 100.0); + + let (segs, lasso_path) = rect_lasso(180.0, -50.0, 220.0, 150.0); + g.insert_stroke(&segs, None, None, 1.0); + g.gc_invisible_edges(); + assert_all_fills_connected(&g); + + let inside: Vec = g.fills.iter().enumerate() + .filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32)) + .filter(|&fid| lasso_path.winding(fill_centroid(&g, fid)) != 0).collect(); + let dump: Vec<_> = g.fills.iter().enumerate().filter(|(_, f)| !f.deleted) + .map(|(i, _)| (i, fill_centroid(&g, FillId(i as u32)), describe_boundary(&g, FillId(i as u32)).len())).collect(); + assert_eq!(inside.len(), 1, "one strip of the right rect should be inside; got {dump:#?}"); +} + +/// No live fill may reference a freed edge or vertex (whose slot a later alloc reuses). +fn assert_no_freed_but_referenced(g: &VectorGraph) { + let freed_v: std::collections::HashSet = g.free_vertices.iter().copied().collect(); + let freed_e: std::collections::HashSet = g.free_edges.iter().copied().collect(); + for (fi, f) in g.fills.iter().enumerate() { + if f.deleted { continue; } + for &(e, _) in &f.boundary { + if e.is_none() { continue; } + assert!(!freed_e.contains(&e.0), "live fill {fi} references freed edge {}", e.0); + for &v in &g.edge(e).vertices { + assert!(!freed_v.contains(&v.0), "live fill {fi} references freed vertex {}", v.0); + } + } + } +} + +#[test] +fn extract_after_cut_keeps_remaining_fill_intact() { + // Cut a rectangle's corner with a lasso, then extract (Group) the clipped corner. The + // extraction must not free vertices/edges still referenced by the L-shaped remainder — + // previously it freed the shared cut vertices, and a later `alloc_vertex` reused those + // slots and corrupted the remainder (the "second lasso deletes all faces" bug). + let mut g = editor_filled_rect(0.0, 0.0, 200.0, 100.0); + let (segs, lasso) = rect_lasso(100.0, -50.0, 300.0, 50.0); // clips the top-right corner + g.insert_stroke(&segs, None, None, 1.0); + g.gc_invisible_edges(); + + // Pick the fill inside the lasso (the clipped corner) and extract it. + let inside: Vec = g.fills.iter().enumerate() + .filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32)) + .filter(|&fid| lasso.winding(g.fill_interior_point(fid)) != 0).collect(); + assert_eq!(inside.len(), 1, "one corner fill should be inside the lasso"); + let inside_fills: HashSet = inside.iter().copied().collect(); + let inside_edges: HashSet = g.fill(inside[0]).boundary.iter() + .filter_map(|&(e, _)| (!e.is_none()).then_some(e)).collect(); + + let _ = g.extract_subgraph(&inside_edges, &inside_fills, &HashSet::new()); + + assert_no_freed_but_referenced(&g); + assert_all_fills_connected(&g); + + // Simulate the next lasso allocating vertices, then re-validate: a corrupted (freed but + // referenced) vertex would now be at a bogus position. + let (segs2, _) = rect_lasso(20.0, 20.0, 60.0, 80.0); + g.insert_stroke(&segs2, None, None, 1.0); + g.gc_invisible_edges(); + assert_all_fills_connected(&g); +} + +/// Captured region-select cases (`LIGHTNINGBEAM_DUMP_REGION=1`), embedded so the regression +/// survives `/tmp` being cleared. Each is `{ "graph": , "segments": [[[x,y]*4]*] }`. +/// They span: two separate rects, side-by-side, overlapping, a notched post-group fill, and +/// dense self-intersecting freehand lassos (dump 3 is the boundary-spike repro). +const REGION_DUMPS: &[&str] = &[ + include_str!("region_dumps/dump0.json"), + include_str!("region_dumps/dump1.json"), + include_str!("region_dumps/dump2.json"), + include_str!("region_dumps/dump3.json"), + include_str!("region_dumps/dump4.json"), +]; + +#[test] +fn dumped_region_selects_are_valid() { + // Replays each captured region-select cut and asserts it yields only valid, non-corrupt + // geometry (no freed-but-referenced refs, no spikes, every fill a connected loop). + for json in REGION_DUMPS { + let v: serde_json::Value = serde_json::from_str(json).unwrap(); + let mut g: VectorGraph = serde_json::from_value(v["graph"].clone()).unwrap(); + let segs: Vec = v["segments"].as_array().unwrap().iter().map(|s| { + let p = |i: usize| { let a = s[i].as_array().unwrap(); Point::new(a[0].as_f64().unwrap(), a[1].as_f64().unwrap()) }; + CubicBez::new(p(0), p(1), p(2), p(3)) + }).collect(); + g.insert_stroke(&segs, None, None, 1.0); + g.gc_invisible_edges(); + assert_no_freed_but_referenced(&g); + assert_no_spikes(&g); + assert_all_fills_connected(&g); + } +} + +#[test] +fn near_coincident_needle_does_not_spike() { + // Smoke test: a stroke poking into a fill and returning along a near-coincident path + // must still yield valid, non-corrupt geometry. (The dense-freehand accordion that the + // boundary-spike collapse specifically fixes is only reliably reproduced by the captured + // region dumps; this is a lightweight portable guard for the same family of inputs.) + let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0); + g.insert_stroke( + &[ + line(Point::new(50.0, -10.0), Point::new(50.0, 50.0)), + line(Point::new(50.0, 50.0), Point::new(50.0003, -10.0)), + ], + None, None, 1.0, + ); + g.gc_invisible_edges(); + assert_no_spikes(&g); + assert_all_fills_connected(&g); +} + +#[test] +fn second_stroke_crossing_first_splits_into_quadrants() { + // A later stroke that crosses an earlier stroke's edge inside a fill triggers an + // edge-domain `split_edge`, whose sub-edges aren't tracked in the second stroke's new + // edges. The retrace must still see them (induced-subgraph expansion) or the cut breaks + // and unravels. Two crossing cuts through a rectangle must yield four connected quadrants. + let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0); + g.insert_stroke(&[line(Point::new(-10.0, 50.0), Point::new(110.0, 50.0))], None, None, 1.0); // horizontal + assert_eq!(g.fills.iter().filter(|f| !f.deleted).count(), 2, "horizontal cut → 2"); + g.insert_stroke(&[line(Point::new(50.0, -10.0), Point::new(50.0, 110.0))], None, None, 1.0); // vertical, crosses it + g.gc_invisible_edges(); + + let live: Vec = g.fills.iter().enumerate() + .filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32)).collect(); + assert_eq!(live.len(), 4, "two crossing cuts → four quadrants; got {}", live.len()); + assert_all_fills_connected(&g); + assert_no_spikes(&g); + for &fid in &live { + assert!((polygon_area(&g, fid) - 2500.0).abs() < 1.0, "each quadrant is 50x50, got {}", polygon_area(&g, fid)); + } +} + +#[test] +fn stroke_dead_ending_inside_fill_does_not_spike() { + // A stroke (e.g. a freehand lasso that wiggles or nearly self-touches) can leave a + // dangling stub: an edge whose inner endpoint has degree 1. Re-tracing must not run out + // and back along it (a self-touching "spike" boundary); the fill stays a clean loop. + let mut g = editor_filled_rect(0.0, 0.0, 200.0, 100.0); + // Open stroke entering the top edge and dead-ending inside at (100,50). + let stub = vec![line(Point::new(50.0, -20.0), Point::new(100.0, 50.0))]; + g.insert_stroke(&stub, None, None, 1.0); + g.gc_invisible_edges(); + + let live: Vec = g.fills.iter().enumerate() + .filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32)).collect(); + assert_eq!(live.len(), 1, "a dead-end stub must not split the fill; got {}", live.len()); + assert_all_fills_connected(&g); + assert_no_spikes(&g); + // The fill is still the full rectangle. + assert!((polygon_area(&g, live[0]) - 20000.0).abs() < 1.0, + "fill should remain the 200x100 rectangle, area {}", polygon_area(&g, live[0])); +} + +#[test] +fn groupfail_two_notched_fills_second_lasso() { + // Faithful reconstruction of the captured post-group state (dump_1): two adjacent + // fills sharing the x=337 column, each with an invisible notch (the hole left by the + // first lasso+group). A second lasso (398,249)-(495,327) clips fill 3's corner. + use std::collections::HashMap; + let mut g = VectorGraph::new(); + let vp = [ + (130.0, 232.0), (337.0, 232.0), (337.0, 362.0), (130.0, 362.0), (337.0, 297.0), + (535.0, 297.0), (535.0, 417.0), (337.0, 417.0), (0.0, 0.0), (337.0, 315.0), + (220.0, 315.0), (456.0, 315.0), (456.0, 345.0), (337.0, 345.0), (220.0, 345.0), + ]; + let vs: Vec<_> = vp.iter().map(|&(x, y)| g.alloc_vertex(Point::new(x, y))).collect(); + let style = StrokeStyle { width: 3.0, ..Default::default() }; + let color = ShapeColor::rgb(0, 0, 0); + // (name, v_start, v_end, visible) + let edge_defs: &[(&str, usize, usize, bool)] = &[ + ("e0", 0, 1, true), ("e1", 4, 5, true), ("e2", 2, 3, true), ("e3", 3, 0, true), + ("e4", 1, 4, true), ("e5", 7, 2, true), ("e6", 5, 6, true), ("e7", 6, 7, true), + ("e8", 10, 9, false), ("e9", 12, 13, false), ("e11", 4, 9, true), ("e12", 9, 11, false), + ("e13", 11, 12, false), ("e15", 13, 2, true), ("e16", 13, 14, false), ("e17", 14, 10, false), + ]; + let mut em: HashMap<&str, EdgeId> = HashMap::new(); + for &(name, a, b, vis) in edge_defs { + let (ss, sc) = if vis { (Some(style.clone()), Some(color)) } else { (None, None) }; + let e = g.alloc_edge(line(vp[a].into(), vp[b].into()), vs[a], vs[b], ss, sc); + em.insert(name, e); + } + let mk = |spec: &[(&str, Direction)]| -> Vec<(EdgeId, Direction)> { + spec.iter().map(|&(n, d)| (em[n], d)).collect() + }; + use Direction::{Backward as B, Forward as F}; + g.alloc_fill(mk(&[("e11", B), ("e4", B), ("e0", B), ("e3", B), ("e2", B), ("e15", B), ("e16", F), ("e17", F), ("e8", F)]), + ShapeColor::rgb(100, 100, 255), FillRule::NonZero); + g.alloc_fill(mk(&[("e15", F), ("e5", B), ("e7", B), ("e6", B), ("e1", B), ("e11", F), ("e12", F), ("e13", F), ("e9", F)]), + ShapeColor::rgb(100, 100, 255), FillRule::NonZero); + + assert_eq!(g.fills.iter().filter(|f| !f.deleted).count(), 2, "starts with 2 fills"); + let (segs, lasso) = rect_lasso(398.0, 249.0, 495.0, 327.0); + g.insert_stroke(&segs, None, None, 1.0); + g.gc_invisible_edges(); + + let live = g.fills.iter().filter(|f| !f.deleted).count(); + let inside: Vec = g.fills.iter().enumerate() + .filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32)) + .filter(|&fid| lasso.winding(g.fill_interior_point(fid)) != 0).collect(); + eprintln!("groupfail: {live} live fills, {} inside lasso", inside.len()); + for (i, f) in g.fills.iter().enumerate() { + if f.deleted { continue; } + let fid = FillId(i as u32); + eprintln!(" fill {i}: bbox {:?} area {:.0}", fill_bbox(&g, fid), polygon_area(&g, fid)); + } + assert_all_fills_connected(&g); + // The two original shapes must survive (no faces wrongly deleted); the lasso adds a cut. + assert!(live >= 2, "must keep both shapes plus the cut; got {live}"); + assert_eq!(inside.len(), 1, "exactly the clipped corner of fill 3 should be selected"); +} + +#[test] +fn lasso_across_notched_fill_never_corrupts_boundaries() { + // A rectangle with a rectangular notch bitten out of its top edge — the shape a fill + // is left as after grouping a lasso selection (the notch is the extracted region's + // hole). A second lasso crossing the notch must never produce disconnected/corrupt + // fill boundaries (it previously incorporated the lasso's out-of-shape edges, drawing + // stray diagonals). It may under-select on such complex geometry, but must stay valid. + let mut g = VectorGraph::new(); + let pts = [ + Point::new(0.0, 0.0), Point::new(80.0, 0.0), Point::new(80.0, 40.0), + Point::new(120.0, 40.0), Point::new(120.0, 0.0), Point::new(200.0, 0.0), + Point::new(200.0, 100.0), Point::new(0.0, 100.0), + ]; + let style = StrokeStyle { width: 1.0, ..Default::default() }; + let color = ShapeColor::rgb(0, 0, 0); + let v: Vec<_> = pts.iter().map(|&p| g.alloc_vertex(p)).collect(); + let mut boundary = Vec::new(); + for i in 0..pts.len() { + let e = g.alloc_edge(line(pts[i], pts[(i + 1) % pts.len()]), v[i], v[(i + 1) % pts.len()], + Some(style.clone()), Some(color)); + boundary.push((e, Direction::Forward)); + } + g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero); + + // Lasso a rectangle that straddles the notch and the shape body. Inside the fill it + // covers (60..140, 0..60) minus the notch (80..120, 0..40) — an arch shape — which the + // cut must isolate as one connected fill, with the remainder outside. + let (segs, lasso) = rect_lasso(60.0, -20.0, 140.0, 60.0); + g.insert_stroke(&segs, None, None, 1.0); + g.gc_invisible_edges(); + + // Every resulting fill is a valid connected loop (never corrupt). + assert_all_fills_connected(&g); + + // Exactly one fill lies inside the lasso, and it is the arch (area = 80*60 - 40*40). + let inside: Vec = g.fills.iter().enumerate() + .filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32)) + .filter(|&fid| lasso.winding(g.fill_interior_point(fid)) != 0).collect(); + assert_eq!(inside.len(), 1, "the arch (lasso ∩ notched fill) should be one selected fill"); + let area = polygon_area(&g, inside[0]); + assert!((area - 3200.0).abs() < 1.0, "arch area should be 3200, got {area}"); +} + +/// Absolute polygon area of a fill from its boundary corner points. +fn polygon_area(g: &VectorGraph, fid: FillId) -> f64 { + let pts: Vec = g.fill(fid).boundary.iter() + .filter(|(e, _)| !e.is_none()) + .map(|&(e, d)| dir_start(g, e, d)) + .collect(); + if pts.len() < 3 { return 0.0; } + let mut a2 = 0.0; + for i in 0..pts.len() { + let p0 = pts[i]; let p1 = pts[(i + 1) % pts.len()]; + a2 += p0.x * p1.y - p1.x * p0.y; + } + (a2 * 0.5).abs() +} + +#[test] +fn two_edge_adjacent_rects_make_two_clean_fills() { + // Two rectangles sharing the x=100 edge. The second's left edge lands exactly on the + // first's right edge; without coincident-edge cleanup this produced duplicate edges and + // zero-area "sliver" fills (4 fills instead of 2) before any lasso. + let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0); + draw_filled_rect(&mut g, 100.0, 0.0, 200.0, 100.0); + let live: Vec<_> = g.fills.iter().enumerate().filter(|(_, f)| !f.deleted) + .map(|(i, _)| (i, fill_bbox(&g, FillId(i as u32)))).collect(); + assert_eq!(live.len(), 2, "two adjacent rects should make exactly two fills; got {live:?}"); + assert_all_fills_connected(&g); +} + +#[test] +fn lasso_strip_through_adjacent_second_rect() { + // Two rectangles sharing an edge (snapped adjacent), lasso a strip through the second. + let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0); + draw_filled_rect(&mut g, 100.0, 0.0, 200.0, 100.0); // shares the x=100 edge with the first + + let (segs, lasso_path) = rect_lasso(130.0, -50.0, 170.0, 150.0); // strip through 2nd + g.insert_stroke(&segs, None, None, 1.0); + g.gc_invisible_edges(); + assert_all_fills_connected(&g); + + let inside: Vec = g.fills.iter().enumerate() + .filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32)) + .filter(|&fid| lasso_path.winding(fill_centroid(&g, fid)) != 0).collect(); + let dump: Vec<_> = g.fills.iter().enumerate().filter(|(_, f)| !f.deleted) + .map(|(i, _)| (i, fill_centroid(&g, FillId(i as u32)), describe_boundary(&g, FillId(i as u32)).len())).collect(); + assert_eq!(inside.len(), 1, "one strip of the 2nd rect should be inside; got {dump:#?}"); +} + +#[test] +fn lasso_strip_through_overlapping_second_rect() { + // Second rectangle overlaps the first; lasso a strip through the second's free part. + let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0); + draw_filled_rect(&mut g, 60.0, 60.0, 200.0, 160.0); + + let (segs, _lasso) = rect_lasso(120.0, 40.0, 160.0, 180.0); + g.insert_stroke(&segs, None, None, 1.0); + g.gc_invisible_edges(); + assert_all_fills_connected(&g); +} + +/// Bounding box of a fill's boundary edge endpoints. +fn fill_bbox(g: &VectorGraph, fid: FillId) -> (f64, f64, f64, f64) { + let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for &(eid, _) in &g.fill(fid).boundary { + if eid.is_none() { + continue; + } + for &vid in &g.edge(eid).vertices { + let p = g.vertex(vid).position; + minx = minx.min(p.x); + miny = miny.min(p.y); + maxx = maxx.max(p.x); + maxy = maxy.max(p.y); + } + } + (minx, miny, maxx, maxy) +} + +#[test] +fn lasso_corner_clip_of_second_rect_splits_off_corner() { + // Captured repro (/tmp dump): two separate rects; the lasso clips the top-left CORNER + // of the second rect, so the cut path turns a corner at a vertex INTERIOR to the rect. + let mut g = editor_filled_rect(128.37, 255.97, 290.91, 336.55); // rect 1 + draw_filled_rect(&mut g, 417.39, 311.62, 620.25, 442.55); // rect 2 + let (segs, lasso) = rect_lasso(360.12, 277.92, 537.32, 397.14); // clips rect-2 corner + g.insert_stroke(&segs, None, None, 1.0); + g.gc_invisible_edges(); + assert_all_fills_connected(&g); + + let inside: Vec = g.fills.iter().enumerate() + .filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32)) + .filter(|&fid| lasso.winding(fill_centroid(&g, fid)) != 0).collect(); + let dump: Vec<_> = g.fills.iter().enumerate().filter(|(_, f)| !f.deleted) + .map(|(i, _)| (i, fill_centroid(&g, FillId(i as u32)), fill_bbox(&g, FillId(i as u32)), describe_boundary(&g, FillId(i as u32)).len())).collect(); + assert_eq!(inside.len(), 1, "exactly the clipped corner should be inside; fills = {dump:#?}"); + + // The selected fill must be the clipped CORNER, not the whole rect 2. + let (minx, miny, maxx, maxy) = fill_bbox(&g, inside[0]); + let eps = 1.0; + assert!( + (minx - 417.39).abs() < eps && (miny - 311.62).abs() < eps + && (maxx - 537.32).abs() < eps && (maxy - 397.14).abs() < eps, + "expected clipped-corner bbox (417.4,311.6)-(537.3,397.1), got ({minx:.1},{miny:.1})-({maxx:.1},{maxy:.1}) \ + — whole rect2 is (417,311)-(620,442); the fill wasn't split" + ); +} + +#[test] +fn lasso_over_middle_of_rect_selects_clean_center_strip() { + // Rectangle (0,0)-(200,100); lasso a vertical strip (50,-50)-(150,150). + let mut g = editor_filled_rect(0.0, 0.0, 200.0, 100.0); + let (segs, lasso_path) = rect_lasso(50.0, -50.0, 150.0, 150.0); + + g.insert_stroke(&segs, None, None, 1.0); + // The editor runs this right after the cut: the lasso's top/bottom edges and the + // out-of-shape extensions of its sides are invisible and unreferenced — they must be + // collected so they can't be selected/edited later. + g.gc_invisible_edges(); + for (i, e) in g.edges.iter().enumerate() { + if e.deleted || e.stroke_style.is_some() || e.stroke_color.is_some() { + continue; + } + let eid = EdgeId(i as u32); + let in_a_fill = g + .fills + .iter() + .any(|f| !f.deleted && f.boundary.iter().any(|&(fe, _)| fe == eid)); + assert!( + in_a_fill, + "stray invisible edge {i} ({:?}) survived gc — not part of any fill", + e.curve + ); + } + + // Classify fills by centroid winding (the editor's logic). + let inside_fills: Vec = g + .fills + .iter() + .enumerate() + .filter(|(_, f)| !f.deleted) + .map(|(i, _)| FillId(i as u32)) + .filter(|&fid| lasso_path.winding(fill_centroid(&g, fid)) != 0) + .collect(); + + let dump = || { + g.fills + .iter() + .enumerate() + .filter(|(_, f)| !f.deleted) + .map(|(i, _)| { + let fid = FillId(i as u32); + (i, fill_centroid(&g, fid), describe_boundary(&g, fid)) + }) + .collect::>() + }; + + assert_eq!( + inside_fills.len(), + 1, + "expected exactly one inside fill (the center strip); live fills = {:#?}", + dump() + ); + + let fid = inside_fills[0]; + let boundary = describe_boundary(&g, fid); + + // The center strip is a rectangle: it must have 4 boundary edges that form a + // *connected closed loop* (each edge's directed end == the next edge's directed start). + // Before the fix, the split produced a 2-edge boundary (left cut + top segment) whose + // bbox is still (50,0)-(150,100) but which `fill_to_bezpath` renders as left+top edges + // plus a diagonal close — the artifact the user reported. + assert_eq!( + boundary.len(), + 4, + "center strip should have 4 boundary edges, got {}: {:#?}", + boundary.len(), + boundary + ); + + let eps = 1e-6; + let n = boundary.len(); + for i in 0..n { + let (_, _, end) = boundary[i]; + let (_, next_start, _) = boundary[(i + 1) % n]; + assert!( + (end.x - next_start.x).abs() < eps && (end.y - next_start.y).abs() < eps, + "boundary is disconnected between edge {i} (ends {end:?}) and edge {} \ + (starts {next_start:?}); full boundary = {boundary:#?}", + (i + 1) % n + ); + } + + // And the loop should visit the four expected corners. + let corners = [ + Point::new(50.0, 0.0), + Point::new(150.0, 0.0), + Point::new(150.0, 100.0), + Point::new(50.0, 100.0), + ]; + for c in corners { + let hit = boundary + .iter() + .any(|&(_, s, _)| (s.x - c.x).abs() < 0.5 && (s.y - c.y).abs() < 0.5); + assert!(hit, "center strip boundary should pass through corner {c:?}: {boundary:#?}"); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_dumps/dump0.json b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_dumps/dump0.json new file mode 100644 index 0000000..a667d3c --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_dumps/dump0.json @@ -0,0 +1 @@ +{"graph":{"edges":[{"curve":{"p0":{"x":193.47576904296875,"y":236.10092163085938},"p1":{"x":261.65675862630206,"y":236.10092163085938},"p2":{"x":329.8377482096354,"y":236.10092163085938},"p3":{"x":398.01873779296875,"y":236.10092163085938}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[0,1]},{"curve":{"p0":{"x":398.01873779296875,"y":312.4524841308594},"p1":{"x":438.86769612630206,"y":312.4524841308594},"p2":{"x":479.7166544596354,"y":312.4524841308594},"p3":{"x":520.5656127929688,"y":312.4524841308594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[4,5]},{"curve":{"p0":{"x":398.01873779296875,"y":366.1438903808594},"p1":{"x":329.83774820963544,"y":366.1438903808594},"p2":{"x":261.6567586263021,"y":366.1438903808594},"p3":{"x":193.47576904296875,"y":366.1438903808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[2,3]},{"curve":{"p0":{"x":193.47576904296875,"y":366.1438903808594},"p1":{"x":193.47576904296875,"y":322.7962341308594},"p2":{"x":193.47576904296875,"y":279.4485778808594},"p3":{"x":193.47576904296875,"y":236.10092163085938}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[3,0]},{"curve":{"p0":{"x":398.01873779296875,"y":236.10092163085938},"p1":{"x":398.01873779296875,"y":261.5514424641927},"p2":{"x":398.01873779296875,"y":287.001963297526},"p3":{"x":398.01873779296875,"y":312.4524841308593}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[1,4]},{"curve":{"p0":{"x":398.01873779296875,"y":402.0149841308594},"p1":{"x":398.01873779296875,"y":390.0579528808594},"p2":{"x":398.01873779296875,"y":378.1009216308594},"p3":{"x":398.01873779296875,"y":366.1438903808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[7,2]},{"curve":{"p0":{"x":520.5656127929688,"y":312.4524841308594},"p1":{"x":520.5656127929688,"y":342.30665079752606},"p2":{"x":520.5656127929688,"y":372.1608174641927},"p3":{"x":520.5656127929688,"y":402.0149841308594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[5,6]},{"curve":{"p0":{"x":520.5656127929688,"y":402.0149841308594},"p1":{"x":479.71665445963544,"y":402.0149841308594},"p2":{"x":438.8676961263021,"y":402.0149841308594},"p3":{"x":398.01873779296875,"y":402.0149841308594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[6,7]},{"curve":{"p0":{"x":398.01873779296875,"y":312.4524841308593},"p1":{"x":398.01873779296875,"y":321.45348485310865},"p2":{"x":398.01873779296875,"y":330.45448557535804},"p3":{"x":398.01873779296875,"y":339.4554862976074}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[4,8]},{"curve":{"p0":{"x":398.01873779296875,"y":339.4554862976074},"p1":{"x":398.01873779296875,"y":348.3516209920247},"p2":{"x":398.01873779296875,"y":357.247755686442},"p3":{"x":398.01873779296875,"y":366.1438903808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[8,2]},{"curve":{"p0":{"x":398.01873779296875,"y":366.1438903808594},"p1":{"x":398.01873779296875,"y":357.13886515299475},"p2":{"x":398.01873779296875,"y":348.1338399251302},"p3":{"x":398.01873779296875,"y":339.1288146972656}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[2,8]},{"curve":{"p0":{"x":398.01873779296875,"y":339.1288146972656},"p1":{"x":398.01873779296875,"y":330.2367045084635},"p2":{"x":398.01873779296875,"y":321.34459431966144},"p3":{"x":398.01873779296875,"y":312.4524841308594}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[8,4]}],"fills":[{"boundary":[[0,"Forward"],[4,"Forward"],[8,"Forward"],[9,"Forward"],[2,"Forward"],[3,"Forward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":false,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null},{"boundary":[[1,"Backward"],[8,"Forward"],[9,"Forward"],[5,"Backward"],[7,"Backward"],[6,"Backward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":false,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null}],"free_edges":[10,11],"free_fills":[],"free_vertices":[],"vertices":[{"deleted":false,"position":{"x":193.47576904296875,"y":236.10092163085938}},{"deleted":false,"position":{"x":398.01873779296875,"y":236.10092163085938}},{"deleted":false,"position":{"x":398.01873779296875,"y":366.1438903808594}},{"deleted":false,"position":{"x":193.47576904296875,"y":366.1438903808594}},{"deleted":false,"position":{"x":398.01873779296875,"y":312.4524841308594}},{"deleted":false,"position":{"x":520.5656127929688,"y":312.4524841308594}},{"deleted":false,"position":{"x":520.5656127929688,"y":402.0149841308594}},{"deleted":false,"position":{"x":398.01873779296875,"y":402.0149841308594}},{"deleted":false,"position":{"x":398.01873779296875,"y":339.1288146972656}}]},"segments":[[[288.28826904296875,417.6712341308594],[287.40936279296875,413.8626403808594],[286.53045654296875,410.0540466308594],[285.65155029296875,406.2454528808594]],[[285.65155029296875,406.2454528808594],[284.18670654296875,400.9733174641927],[282.72186279296875,395.70118204752606],[281.25701904296875,390.4290466308594]],[[281.25701904296875,390.4290466308594],[280.96405029296875,387.5006612141927],[280.67108154296875,384.57227579752606],[280.37811279296875,381.6438903808594]],[[280.37811279296875,381.6438903808594],[280.37811279296875,379.0071716308594],[280.37811279296875,376.3704528808594],[280.37811279296875,373.7337341308594]],[[280.37811279296875,373.7337341308594],[280.37811279296875,371.9772237141927],[280.37811279296875,370.22071329752606],[280.37811279296875,368.4642028808594]],[[280.37811279296875,368.4642028808594],[280.67108154296875,366.9993591308594],[280.96405029296875,365.5345153808594],[281.25701904296875,364.0696716308594]],[[281.25701904296875,364.0696716308594],[282.72056070963544,362.3131612141927],[284.18410237630206,360.55665079752606],[285.64764404296875,358.8001403808594]],[[285.64764404296875,358.8001403808594],[286.81561279296875,358.21680704752606],[287.98358154296875,357.6334737141927],[289.15155029296875,357.0501403808594]],[[289.15155029296875,357.0501403808594],[290.59295654296875,356.47461954752606],[292.03436279296875,355.8990987141927],[293.47576904296875,355.3235778808594]],[[293.47576904296875,355.3235778808594],[294.64634195963544,354.7389424641927],[295.81691487630206,354.15430704752606],[296.98748779296875,353.5696716308594]],[[296.98748779296875,353.5696716308594],[298.45233154296875,353.5696716308594],[299.91717529296875,353.5696716308594],[301.38201904296875,353.5696716308594]],[[301.38201904296875,353.5696716308594],[303.72446695963544,352.3977966308594],[306.06691487630206,351.2259216308594],[308.40936279296875,350.0540466308594]],[[308.40936279296875,350.0540466308594],[310.75311279296875,349.4681091308594],[313.09686279296875,348.8821716308594],[315.44061279296875,348.2962341308594]],[[315.44061279296875,348.2962341308594],[316.90545654296875,347.7102966308594],[318.37030029296875,347.1243591308594],[319.83514404296875,346.5384216308594]],[[319.83514404296875,346.5384216308594],[321.59165445963544,345.6608174641927],[323.34816487630206,344.78321329752606],[325.10467529296875,343.9056091308594]],[[325.10467529296875,343.9056091308594],[328.32733154296875,342.4407653808594],[331.54998779296875,340.9759216308594],[334.77264404296875,339.5110778808594]],[[334.77264404296875,339.5110778808594],[337.11509195963544,338.6321716308594],[339.45753987630206,337.7532653808594],[341.79998779296875,336.8743591308594]],[[341.79998779296875,336.8743591308594],[344.43670654296875,335.9954528808594],[347.07342529296875,335.1165466308594],[349.71014404296875,334.2376403808594]],[[349.71014404296875,334.2376403808594],[351.17368570963544,333.9446716308594],[352.63722737630206,333.6517028808594],[354.10076904296875,333.3587341308594]],[[354.10076904296875,333.3587341308594],[355.85858154296875,333.0657653808594],[357.61639404296875,332.7727966308594],[359.37420654296875,332.4798278808594]],[[359.37420654296875,332.4798278808594],[361.42498779296875,332.4798278808594],[363.47576904296875,332.4798278808594],[365.52655029296875,332.4798278808594]],[[365.52655029296875,332.4798278808594],[367.28306070963544,332.4798278808594],[369.03957112630206,332.4798278808594],[370.79608154296875,332.4798278808594]],[[370.79608154296875,332.4798278808594],[372.26092529296875,332.4798278808594],[373.72576904296875,332.4798278808594],[375.19061279296875,332.4798278808594]],[[375.19061279296875,332.4798278808594],[377.24139404296875,332.4798278808594],[379.29217529296875,332.4798278808594],[381.34295654296875,332.4798278808594]],[[381.34295654296875,332.4798278808594],[382.80649820963544,332.7727966308594],[384.27003987630206,333.0657653808594],[385.73358154296875,333.3587341308594]],[[385.73358154296875,333.3587341308594],[387.19842529296875,333.3587341308594],[388.66326904296875,333.3587341308594],[390.12811279296875,333.3587341308594]],[[390.12811279296875,333.3587341308594],[391.88592529296875,333.3587341308594],[393.64373779296875,333.3587341308594],[395.40155029296875,333.3587341308594]],[[395.40155029296875,333.3587341308594],[398.32993570963544,333.9446716308594],[401.25832112630206,334.5306091308594],[404.18670654296875,335.1165466308594]],[[404.18670654296875,335.1165466308594],[406.23748779296875,335.1165466308594],[408.28826904296875,335.1165466308594],[410.33905029296875,335.1165466308594]],[[410.33905029296875,335.1165466308594],[412.09556070963544,335.4095153808594],[413.85207112630206,335.7024841308594],[415.60858154296875,335.9954528808594]],[[415.60858154296875,335.9954528808594],[418.24530029296875,335.9954528808594],[420.88201904296875,335.9954528808594],[423.51873779296875,335.9954528808594]],[[423.51873779296875,335.9954528808594],[424.69061279296875,335.9954528808594],[425.86248779296875,335.9954528808594],[427.03436279296875,335.9954528808594]],[[427.03436279296875,335.9954528808594],[429.37681070963544,336.2884216308594],[431.71925862630206,336.5813903808594],[434.06170654296875,336.8743591308594]],[[434.06170654296875,336.8743591308594],[435.23358154296875,337.1673278808594],[436.40545654296875,337.4602966308594],[437.57733154296875,337.7532653808594]],[[437.57733154296875,337.7532653808594],[438.45623779296875,338.3392028808594],[439.33514404296875,338.9251403808594],[440.21405029296875,339.5110778808594]],[[440.21405029296875,339.5110778808594],[441.38592529296875,339.8040466308594],[442.55780029296875,340.0970153808594],[443.72967529296875,340.3899841308594]],[[443.72967529296875,340.3899841308594],[444.90024820963544,341.5618591308594],[446.07082112630206,342.7337341308594],[447.24139404296875,343.9056091308594]],[[447.24139404296875,343.9056091308594],[448.12030029296875,344.49024454752606],[448.99920654296875,345.0748799641927],[449.87811279296875,345.6595153808594]],[[449.87811279296875,345.6595153808594],[450.75701904296875,346.5384216308594],[451.63592529296875,347.4173278808594],[452.51483154296875,348.2962341308594]],[[452.51483154296875,348.2962341308594],[453.68670654296875,349.7610778808594],[454.85858154296875,351.2259216308594],[456.03045654296875,352.6907653808594]],[[456.03045654296875,352.6907653808594],[457.20233154296875,355.32618204752606],[458.37420654296875,357.9615987141927],[459.54608154296875,360.5970153808594]],[[459.54608154296875,360.5970153808594],[459.83774820963544,361.7688903808594],[460.12941487630206,362.9407653808594],[460.42108154296875,364.1126403808594]],[[460.42108154296875,364.1126403808594],[460.71405029296875,365.5774841308594],[461.00701904296875,367.0423278808594],[461.29998779296875,368.5071716308594]],[[461.29998779296875,368.5071716308594],[461.59295654296875,370.84961954752606],[461.88592529296875,373.1920674641927],[462.17889404296875,375.5345153808594]],[[462.17889404296875,375.5345153808594],[462.17889404296875,377.2923278808594],[462.17889404296875,379.0501403808594],[462.17889404296875,380.8079528808594]],[[462.17889404296875,380.8079528808594],[462.17889404296875,382.5657653808594],[462.17889404296875,384.3235778808594],[462.17889404296875,386.0813903808594]],[[462.17889404296875,386.0813903808594],[462.17889404296875,388.13086954752606],[462.17889404296875,390.1803487141927],[462.17889404296875,392.2298278808594]],[[462.17889404296875,392.2298278808594],[461.88592529296875,393.4017028808594],[461.59295654296875,394.5735778808594],[461.29998779296875,395.7454528808594]],[[461.29998779296875,395.7454528808594],[460.71535237630206,397.2102966308594],[460.13071695963544,398.6751403808594],[459.54608154296875,400.1399841308594]],[[459.54608154296875,400.1399841308594],[458.66717529296875,403.06836954752606],[457.78826904296875,405.9967549641927],[456.90936279296875,408.9251403808594]],[[456.90936279296875,408.9251403808594],[456.32342529296875,411.2688903808594],[455.73748779296875,413.6126403808594],[455.15155029296875,415.9563903808594]],[[455.15155029296875,415.9563903808594],[453.97967529296875,419.17774454752606],[452.80780029296875,422.3990987141927],[451.63592529296875,425.6204528808594]],[[451.63592529296875,425.6204528808594],[451.04998779296875,427.0852966308594],[450.46405029296875,428.5501403808594],[449.87811279296875,430.0149841308594]],[[449.87811279296875,430.0149841308594],[449.29217529296875,431.4798278808594],[448.70623779296875,432.9446716308594],[448.12030029296875,434.4095153808594]],[[448.12030029296875,434.4095153808594],[446.94842529296875,436.45899454752606],[445.77655029296875,438.5084737141927],[444.60467529296875,440.5579528808594]],[[444.60467529296875,440.5579528808594],[443.43410237630206,442.0227966308594],[442.26352945963544,443.4876403808594],[441.09295654296875,444.9524841308594]],[[441.09295654296875,444.9524841308594],[439.92108154296875,445.5384216308594],[438.74920654296875,446.1243591308594],[437.57733154296875,446.7102966308594]],[[437.57733154296875,446.7102966308594],[436.11248779296875,447.5892028808594],[434.64764404296875,448.4681091308594],[433.18280029296875,449.3470153808594]],[[433.18280029296875,449.3470153808594],[431.71795654296875,449.3470153808594],[430.25311279296875,449.3470153808594],[428.78826904296875,449.3470153808594]],[[428.78826904296875,449.3470153808594],[426.44582112630206,449.3470153808594],[424.10337320963544,449.3470153808594],[421.76092529296875,449.3470153808594]],[[421.76092529296875,449.3470153808594],[419.71014404296875,449.6399841308594],[417.65936279296875,449.9329528808594],[415.60858154296875,450.2259216308594]],[[415.60858154296875,450.2259216308594],[411.50832112630206,450.2259216308594],[407.40806070963544,450.2259216308594],[403.30780029296875,450.2259216308594]],[[403.30780029296875,450.2259216308594],[400.37941487630206,450.2259216308594],[397.45102945963544,450.2259216308594],[394.52264404296875,450.2259216308594]],[[394.52264404296875,450.2259216308594],[391.88592529296875,450.2259216308594],[389.24920654296875,450.2259216308594],[386.61248779296875,450.2259216308594]],[[386.61248779296875,450.2259216308594],[382.51222737630206,449.0540466308594],[378.41196695963544,447.8821716308594],[374.31170654296875,446.7102966308594]],[[374.31170654296875,446.7102966308594],[372.26092529296875,446.1243591308594],[370.21014404296875,445.5384216308594],[368.15936279296875,444.9524841308594]],[[368.15936279296875,444.9524841308594],[366.40285237630206,444.6595153808594],[364.64634195963544,444.3665466308594],[362.88983154296875,444.0735778808594]],[[362.88983154296875,444.0735778808594],[360.54608154296875,443.4876403808594],[358.20233154296875,442.9017028808594],[355.85858154296875,442.3157653808594]],[[355.85858154296875,442.3157653808594],[354.39503987630206,441.7298278808594],[352.93149820963544,441.1438903808594],[351.46795654296875,440.5579528808594]],[[351.46795654296875,440.5579528808594],[330.40806070963544,432.9290466308594],[309.34816487630206,425.3001403808594],[288.28826904296875,417.6712341308594]]]} \ No newline at end of file diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_dumps/dump1.json b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_dumps/dump1.json new file mode 100644 index 0000000..a4ee52f --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_dumps/dump1.json @@ -0,0 +1 @@ +{"graph":{"edges":[{"curve":{"p0":{"x":193.47576904296875,"y":236.10092163085938},"p1":{"x":261.65675862630206,"y":236.10092163085938},"p2":{"x":329.8377482096354,"y":236.10092163085938},"p3":{"x":398.01873779296875,"y":236.10092163085938}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[0,1]},{"curve":{"p0":{"x":398.01873779296875,"y":312.4524841308594},"p1":{"x":438.86769612630206,"y":312.4524841308594},"p2":{"x":479.7166544596354,"y":312.4524841308594},"p3":{"x":520.5656127929688,"y":312.4524841308594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[4,5]},{"curve":{"p0":{"x":398.01873779296875,"y":366.1438903808594},"p1":{"x":329.83774820963544,"y":366.1438903808594},"p2":{"x":261.6567586263021,"y":366.1438903808594},"p3":{"x":193.47576904296875,"y":366.1438903808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[2,3]},{"curve":{"p0":{"x":193.47576904296875,"y":366.1438903808594},"p1":{"x":193.47576904296875,"y":322.7962341308594},"p2":{"x":193.47576904296875,"y":279.4485778808594},"p3":{"x":193.47576904296875,"y":236.10092163085938}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[3,0]},{"curve":{"p0":{"x":398.01873779296875,"y":236.10092163085938},"p1":{"x":398.01873779296875,"y":261.5514424641927},"p2":{"x":398.01873779296875,"y":287.001963297526},"p3":{"x":398.01873779296875,"y":312.4524841308593}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[1,4]},{"curve":{"p0":{"x":398.01873779296875,"y":402.0149841308594},"p1":{"x":398.01873779296875,"y":390.0579528808594},"p2":{"x":398.01873779296875,"y":378.1009216308594},"p3":{"x":398.01873779296875,"y":366.1438903808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[7,2]},{"curve":{"p0":{"x":520.5656127929688,"y":312.4524841308594},"p1":{"x":520.5656127929688,"y":342.30665079752606},"p2":{"x":520.5656127929688,"y":372.1608174641927},"p3":{"x":520.5656127929688,"y":402.0149841308594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[5,6]},{"curve":{"p0":{"x":520.5656127929688,"y":402.0149841308594},"p1":{"x":479.71665445963544,"y":402.0149841308594},"p2":{"x":438.8676961263021,"y":402.0149841308594},"p3":{"x":398.01873779296875,"y":402.0149841308594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[6,7]},{"curve":{"p0":{"x":398.01873779296875,"y":312.4524841308593},"p1":{"x":398.01873779296875,"y":321.45348485310865},"p2":{"x":398.01873779296875,"y":330.45448557535804},"p3":{"x":398.01873779296875,"y":339.4554862976074}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[4,8]},{"curve":{"p0":{"x":398.01873779296875,"y":339.4554862976074},"p1":{"x":398.01873779296875,"y":348.3516209920247},"p2":{"x":398.01873779296875,"y":357.247755686442},"p3":{"x":398.01873779296875,"y":366.1438903808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[8,2]},{"curve":{"p0":{"x":398.01873779296875,"y":366.1438903808594},"p1":{"x":398.01873779296875,"y":357.13886515299475},"p2":{"x":398.01873779296875,"y":348.1338399251302},"p3":{"x":398.01873779296875,"y":339.1288146972656}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[2,8]},{"curve":{"p0":{"x":398.01873779296875,"y":339.1288146972656},"p1":{"x":398.01873779296875,"y":330.2367045084635},"p2":{"x":398.01873779296875,"y":321.34459431966144},"p3":{"x":398.01873779296875,"y":312.4524841308594}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[8,4]}],"fills":[{"boundary":[[0,"Forward"],[4,"Forward"],[8,"Forward"],[9,"Forward"],[2,"Forward"],[3,"Forward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":false,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null},{"boundary":[[1,"Backward"],[8,"Forward"],[9,"Forward"],[5,"Backward"],[7,"Backward"],[6,"Backward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":false,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null}],"free_edges":[10,11],"free_fills":[],"free_vertices":[],"vertices":[{"deleted":false,"position":{"x":193.47576904296875,"y":236.10092163085938}},{"deleted":false,"position":{"x":398.01873779296875,"y":236.10092163085938}},{"deleted":false,"position":{"x":398.01873779296875,"y":366.1438903808594}},{"deleted":false,"position":{"x":193.47576904296875,"y":366.1438903808594}},{"deleted":false,"position":{"x":398.01873779296875,"y":312.4524841308594}},{"deleted":false,"position":{"x":520.5656127929688,"y":312.4524841308594}},{"deleted":false,"position":{"x":520.5656127929688,"y":402.0149841308594}},{"deleted":false,"position":{"x":398.01873779296875,"y":402.0149841308594}},{"deleted":false,"position":{"x":398.01873779296875,"y":339.1288146972656}}]},"segments":[[[271.79217529296875,267.5188903808594],[269.44842529296875,266.3470153808594],[267.10467529296875,265.1751403808594],[264.76092529296875,264.0032653808594]],[[264.76092529296875,264.0032653808594],[262.12550862630206,262.2467549641927],[259.49009195963544,260.49024454752606],[256.85467529296875,258.7337341308594]],[[256.85467529296875,258.7337341308594],[254.80389404296875,256.9759216308594],[252.75311279296875,255.21810913085938],[250.70233154296875,253.46029663085938]],[[250.70233154296875,253.46029663085938],[248.94451904296875,251.70248413085938],[247.18670654296875,249.94467163085938],[245.42889404296875,248.18685913085938]],[[245.42889404296875,248.18685913085938],[243.6723836263021,246.13737996419272],[241.9158732096354,244.08790079752603],[240.15936279296875,242.03842163085938]],[[240.15936279296875,242.03842163085938],[238.69451904296875,239.69467163085938],[237.22967529296875,237.35092163085938],[235.76483154296875,235.00717163085938]],[[235.76483154296875,235.00717163085938],[234.88592529296875,233.25066121419272],[234.00701904296875,231.49415079752603],[233.12811279296875,229.73764038085938]],[[233.12811279296875,229.73764038085938],[232.54217529296875,228.27279663085938],[231.95623779296875,226.80795288085938],[231.37030029296875,225.34310913085938]],[[231.37030029296875,225.34310913085938],[231.37030029296875,224.17123413085938],[231.37030029296875,222.99935913085938],[231.37030029296875,221.82748413085938]],[[231.37030029296875,221.82748413085938],[231.66326904296875,219.77800496419272],[231.95623779296875,217.72852579752603],[232.24920654296875,215.67904663085938]],[[232.24920654296875,215.67904663085938],[233.12811279296875,215.09310913085938],[234.00701904296875,214.50717163085938],[234.88592529296875,213.92123413085938]],[[234.88592529296875,213.92123413085938],[235.76483154296875,213.33529663085938],[236.64373779296875,212.74935913085938],[237.52264404296875,212.16342163085938]],[[237.52264404296875,212.16342163085938],[238.98748779296875,211.28451538085938],[240.45233154296875,210.40560913085938],[241.91717529296875,209.52670288085938]],[[241.91717529296875,209.52670288085938],[243.0877482096354,209.23373413085938],[244.2583211263021,208.94076538085938],[245.42889404296875,208.64779663085938]],[[245.42889404296875,208.64779663085938],[246.60076904296875,208.35482788085938],[247.77264404296875,208.06185913085938],[248.94451904296875,207.76889038085938]],[[248.94451904296875,207.76889038085938],[250.40936279296875,207.47592163085938],[251.87420654296875,207.18295288085938],[253.33905029296875,206.88998413085938]],[[253.33905029296875,206.88998413085938],[255.3885294596354,206.30404663085938],[257.43800862630206,205.71810913085938],[259.48748779296875,205.13217163085938]],[[259.48748779296875,205.13217163085938],[262.71014404296875,204.83920288085938],[265.93280029296875,204.54623413085938],[269.15545654296875,204.25326538085938]],[[269.15545654296875,204.25326538085938],[271.79087320963544,204.25326538085938],[274.42628987630206,204.25326538085938],[277.06170654296875,204.25326538085938]],[[277.06170654296875,204.25326538085938],[279.69842529296875,204.25326538085938],[282.33514404296875,204.25326538085938],[284.97186279296875,204.25326538085938]],[[284.97186279296875,204.25326538085938],[288.77915445963544,204.83920288085938],[292.58644612630206,205.42514038085938],[296.39373779296875,206.01107788085938]],[[296.39373779296875,206.01107788085938],[298.73748779296875,207.18295288085938],[301.08123779296875,208.35482788085938],[303.42498779296875,209.52670288085938]],[[303.42498779296875,209.52670288085938],[306.06040445963544,210.69857788085938],[308.69582112630206,211.87045288085938],[311.33123779296875,213.04232788085938]],[[311.33123779296875,213.04232788085938],[315.43149820963544,215.97071329752603],[319.53175862630206,218.89909871419272],[323.63201904296875,221.82748413085938]],[[323.63201904296875,221.82748413085938],[326.26873779296875,224.46420288085938],[328.90545654296875,227.10092163085938],[331.54217529296875,229.73764038085938]],[[331.54217529296875,229.73764038085938],[334.17759195963544,232.95899454752603],[336.81300862630206,236.18034871419272],[339.44842529296875,239.40170288085938]],[[339.44842529296875,239.40170288085938],[342.37811279296875,244.96680704752603],[345.30780029296875,250.53191121419272],[348.23748779296875,256.0970153808594]],[[348.23748779296875,256.0970153808594],[349.40806070963544,259.90430704752606],[350.57863362630206,263.7115987141927],[351.74920654296875,267.5188903808594]],[[351.74920654296875,267.5188903808594],[353.50701904296875,273.08399454752606],[355.26483154296875,278.6490987141927],[357.02264404296875,284.2142028808594]],[[357.02264404296875,284.2142028808594],[357.31561279296875,287.4368591308594],[357.60858154296875,290.6595153808594],[357.90155029296875,293.8821716308594]],[[357.90155029296875,293.8821716308594],[357.90155029296875,296.51758829752606],[357.90155029296875,299.1530049641927],[357.90155029296875,301.7884216308594]],[[357.90155029296875,301.7884216308594],[357.60858154296875,305.00977579752606],[357.31561279296875,308.2311299641927],[357.02264404296875,311.4524841308594]],[[357.02264404296875,311.4524841308594],[356.43670654296875,313.2102966308594],[355.85076904296875,314.9681091308594],[355.26483154296875,316.7259216308594]],[[355.26483154296875,316.7259216308594],[354.67889404296875,317.8977966308594],[354.09295654296875,319.0696716308594],[353.50701904296875,320.2415466308594]],[[353.50701904296875,320.2415466308594],[352.92108154296875,321.1204528808594],[352.33514404296875,321.9993591308594],[351.74920654296875,322.8782653808594]],[[351.74920654296875,322.8782653808594],[350.59816487630206,323.1712341308594],[349.44712320963544,323.4642028808594],[348.29608154296875,323.7571716308594]],[[348.29608154296875,323.7571716308594],[322.79477945963544,305.0110778808594],[297.29347737630206,286.2649841308594],[271.79217529296875,267.5188903808594]]]} \ No newline at end of file diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_dumps/dump2.json b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_dumps/dump2.json new file mode 100644 index 0000000..053a0c4 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_dumps/dump2.json @@ -0,0 +1 @@ +{"graph":{"edges":[{"curve":{"p0":{"x":193.47576904296875,"y":236.10092163085938},"p1":{"x":261.65675862630206,"y":236.10092163085938},"p2":{"x":329.8377482096354,"y":236.10092163085938},"p3":{"x":398.01873779296875,"y":236.10092163085938}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[0,1]},{"curve":{"p0":{"x":398.01873779296875,"y":312.4524841308594},"p1":{"x":438.86769612630206,"y":312.4524841308594},"p2":{"x":479.7166544596354,"y":312.4524841308594},"p3":{"x":520.5656127929688,"y":312.4524841308594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[4,5]},{"curve":{"p0":{"x":398.01873779296875,"y":366.1438903808594},"p1":{"x":329.83774820963544,"y":366.1438903808594},"p2":{"x":261.6567586263021,"y":366.1438903808594},"p3":{"x":193.47576904296875,"y":366.1438903808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[2,3]},{"curve":{"p0":{"x":193.47576904296875,"y":366.1438903808594},"p1":{"x":193.47576904296875,"y":322.7962341308594},"p2":{"x":193.47576904296875,"y":279.4485778808594},"p3":{"x":193.47576904296875,"y":236.10092163085938}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[3,0]},{"curve":{"p0":{"x":398.01873779296875,"y":236.10092163085938},"p1":{"x":398.01873779296875,"y":261.5514424641927},"p2":{"x":398.01873779296875,"y":287.001963297526},"p3":{"x":398.01873779296875,"y":312.4524841308593}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[1,4]},{"curve":{"p0":{"x":398.01873779296875,"y":402.0149841308594},"p1":{"x":398.01873779296875,"y":390.0579528808594},"p2":{"x":398.01873779296875,"y":378.1009216308594},"p3":{"x":398.01873779296875,"y":366.1438903808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[7,2]},{"curve":{"p0":{"x":520.5656127929688,"y":312.4524841308594},"p1":{"x":520.5656127929688,"y":342.30665079752606},"p2":{"x":520.5656127929688,"y":372.1608174641927},"p3":{"x":520.5656127929688,"y":402.0149841308594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[5,6]},{"curve":{"p0":{"x":520.5656127929688,"y":402.0149841308594},"p1":{"x":479.71665445963544,"y":402.0149841308594},"p2":{"x":438.8676961263021,"y":402.0149841308594},"p3":{"x":398.01873779296875,"y":402.0149841308594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[6,7]},{"curve":{"p0":{"x":398.01873779296875,"y":312.4524841308593},"p1":{"x":398.01873779296875,"y":321.45348485310865},"p2":{"x":398.01873779296875,"y":330.45448557535804},"p3":{"x":398.01873779296875,"y":339.4554862976074}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[4,8]},{"curve":{"p0":{"x":398.01873779296875,"y":339.4554862976074},"p1":{"x":398.01873779296875,"y":348.3516209920247},"p2":{"x":398.01873779296875,"y":357.247755686442},"p3":{"x":398.01873779296875,"y":366.1438903808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[8,2]},{"curve":{"p0":{"x":398.01873779296875,"y":366.1438903808594},"p1":{"x":398.01873779296875,"y":357.13886515299475},"p2":{"x":398.01873779296875,"y":348.1338399251302},"p3":{"x":398.01873779296875,"y":339.1288146972656}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[2,8]},{"curve":{"p0":{"x":398.01873779296875,"y":339.1288146972656},"p1":{"x":398.01873779296875,"y":330.2367045084635},"p2":{"x":398.01873779296875,"y":321.34459431966144},"p3":{"x":398.01873779296875,"y":312.4524841308594}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[8,4]}],"fills":[{"boundary":[[0,"Forward"],[4,"Forward"],[8,"Forward"],[9,"Forward"],[2,"Forward"],[3,"Forward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":false,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null},{"boundary":[[1,"Backward"],[8,"Forward"],[9,"Forward"],[5,"Backward"],[7,"Backward"],[6,"Backward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":false,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null}],"free_edges":[10,11],"free_fills":[],"free_vertices":[],"vertices":[{"deleted":false,"position":{"x":193.47576904296875,"y":236.10092163085938}},{"deleted":false,"position":{"x":398.01873779296875,"y":236.10092163085938}},{"deleted":false,"position":{"x":398.01873779296875,"y":366.1438903808594}},{"deleted":false,"position":{"x":193.47576904296875,"y":366.1438903808594}},{"deleted":false,"position":{"x":398.01873779296875,"y":312.4524841308594}},{"deleted":false,"position":{"x":520.5656127929688,"y":312.4524841308594}},{"deleted":false,"position":{"x":520.5656127929688,"y":402.0149841308594}},{"deleted":false,"position":{"x":398.01873779296875,"y":402.0149841308594}},{"deleted":false,"position":{"x":398.01873779296875,"y":339.1288146972656}}]},"segments":[[[169.92498779296875,306.4056091308594],[172.85467529296875,305.5267028808594],[175.78436279296875,304.6477966308594],[178.71405029296875,303.7688903808594]],[[178.71405029296875,303.7688903808594],[182.5213419596354,303.1829528808594],[186.3286336263021,302.5970153808594],[190.13592529296875,302.0110778808594]],[[190.13592529296875,302.0110778808594],[194.2361857096354,301.4251403808594],[198.3364461263021,300.8392028808594],[202.43670654296875,300.2532653808594]],[[202.43670654296875,300.2532653808594],[206.5369669596354,299.9615987141927],[210.6372273763021,299.66993204752606],[214.73748779296875,299.3782653808594]],[[214.73748779296875,299.3782653808594],[218.8377482096354,299.0852966308594],[222.9380086263021,298.7923278808594],[227.03826904296875,298.4993591308594]],[[227.03826904296875,298.4993591308594],[230.84686279296875,298.4993591308594],[234.65545654296875,298.4993591308594],[238.46405029296875,298.4993591308594]],[[238.46405029296875,298.4993591308594],[242.2713419596354,298.4993591308594],[246.0786336263021,298.4993591308594],[249.88592529296875,298.4993591308594]],[[249.88592529296875,298.4993591308594],[255.4510294596354,298.7923278808594],[261.01613362630206,299.0852966308594],[266.58123779296875,299.3782653808594]],[[266.58123779296875,299.3782653808594],[270.68149820963544,299.3782653808594],[274.78175862630206,299.3782653808594],[278.88201904296875,299.3782653808594]],[[278.88201904296875,299.3782653808594],[282.68931070963544,299.66993204752606],[286.49660237630206,299.9615987141927],[290.30389404296875,300.2532653808594]],[[290.30389404296875,300.2532653808594],[296.74790445963544,301.4251403808594],[303.19191487630206,302.5970153808594],[309.63592529296875,303.7688903808594]],[[309.63592529296875,303.7688903808594],[314.02915445963544,305.2337341308594],[318.42238362630206,306.6985778808594],[322.81561279296875,308.1634216308594]],[[322.81561279296875,308.1634216308594],[326.33123779296875,309.6282653808594],[329.84686279296875,311.0931091308594],[333.36248779296875,312.5579528808594]],[[333.36248779296875,312.5579528808594],[336.29087320963544,313.72852579752606],[339.21925862630206,314.8990987141927],[342.14764404296875,316.0696716308594]],[[342.14764404296875,316.0696716308594],[345.07602945963544,317.2415466308594],[348.00441487630206,318.4134216308594],[350.93280029296875,319.5852966308594]],[[350.93280029296875,319.5852966308594],[353.56951904296875,320.1712341308594],[356.20623779296875,320.7571716308594],[358.84295654296875,321.3431091308594]],[[358.84295654296875,321.3431091308594],[361.77134195963544,322.5149841308594],[364.69972737630206,323.6868591308594],[367.62811279296875,324.8587341308594]],[[367.62811279296875,324.8587341308594],[370.55780029296875,326.0306091308594],[373.48748779296875,327.2024841308594],[376.41717529296875,328.3743591308594]],[[376.41717529296875,328.3743591308594],[379.05259195963544,329.25196329752606],[381.68800862630206,330.1295674641927],[384.32342529296875,331.0071716308594]],[[384.32342529296875,331.0071716308594],[386.96014404296875,331.3001403808594],[389.59686279296875,331.5931091308594],[392.23358154296875,331.8860778808594]],[[392.23358154296875,331.8860778808594],[394.86899820963544,331.8860778808594],[397.50441487630206,331.8860778808594],[400.13983154296875,331.8860778808594]],[[400.13983154296875,331.8860778808594],[402.48358154296875,331.8860778808594],[404.82733154296875,331.8860778808594],[407.17108154296875,331.8860778808594]],[[407.17108154296875,331.8860778808594],[410.09946695963544,331.8860778808594],[413.02785237630206,331.8860778808594],[415.95623779296875,331.8860778808594]],[[415.95623779296875,331.8860778808594],[418.00701904296875,331.8860778808594],[420.05780029296875,331.8860778808594],[422.10858154296875,331.8860778808594]],[[422.10858154296875,331.8860778808594],[424.74399820963544,331.8860778808594],[427.37941487630206,331.8860778808594],[430.01483154296875,331.8860778808594]],[[430.01483154296875,331.8860778808594],[432.65155029296875,331.8860778808594],[435.28826904296875,331.8860778808594],[437.92498779296875,331.8860778808594]],[[437.92498779296875,331.8860778808594],[440.26743570963544,331.5931091308594],[442.60988362630206,331.3001403808594],[444.95233154296875,331.0071716308594]],[[444.95233154296875,331.0071716308594],[446.41717529296875,331.0071716308594],[447.88201904296875,331.0071716308594],[449.34686279296875,331.0071716308594]],[[449.34686279296875,331.0071716308594],[450.81170654296875,330.7155049641927],[452.27655029296875,330.42383829752606],[453.74139404296875,330.1321716308594]],[[453.74139404296875,330.1321716308594],[455.79087320963544,330.1321716308594],[457.84035237630206,330.1321716308594],[459.88983154296875,330.1321716308594]],[[459.88983154296875,330.1321716308594],[461.06170654296875,330.1321716308594],[462.23358154296875,330.1321716308594],[463.40545654296875,330.1321716308594]],[[463.40545654296875,330.1321716308594],[464.87030029296875,330.42383829752606],[466.33514404296875,330.7155049641927],[467.79998779296875,331.0071716308594]],[[467.79998779296875,331.0071716308594],[468.97186279296875,331.5931091308594],[470.14373779296875,332.1790466308594],[471.31561279296875,332.7649841308594]],[[471.31561279296875,332.7649841308594],[473.36509195963544,334.8157653808594],[475.41457112630206,336.8665466308594],[477.46405029296875,338.9173278808594]],[[477.46405029296875,338.9173278808594],[478.04998779296875,340.0892028808594],[478.63592529296875,341.2610778808594],[479.22186279296875,342.4329528808594]],[[479.22186279296875,342.4329528808594],[479.80780029296875,343.6048278808594],[480.39373779296875,344.7767028808594],[480.97967529296875,345.9485778808594]],[[480.97967529296875,345.9485778808594],[481.27264404296875,347.70508829752606],[481.56561279296875,349.4615987141927],[481.85858154296875,351.2181091308594]],[[481.85858154296875,351.2181091308594],[482.15155029296875,352.6829528808594],[482.44451904296875,354.1477966308594],[482.73748779296875,355.6126403808594]],[[482.73748779296875,355.6126403808594],[483.03045654296875,356.7845153808594],[483.32342529296875,357.9563903808594],[483.61639404296875,359.1282653808594]],[[483.61639404296875,359.1282653808594],[483.61639404296875,360.88477579752606],[483.61639404296875,362.6412862141927],[483.61639404296875,364.3977966308594]],[[483.61639404296875,364.3977966308594],[483.32342529296875,365.5696716308594],[483.03045654296875,366.7415466308594],[482.73748779296875,367.9134216308594]],[[482.73748779296875,367.9134216308594],[481.85858154296875,369.9642028808594],[480.97967529296875,372.0149841308594],[480.10076904296875,374.0657653808594]],[[480.10076904296875,374.0657653808594],[479.51483154296875,374.9446716308594],[478.92889404296875,375.8235778808594],[478.34295654296875,376.7024841308594]],[[478.34295654296875,376.7024841308594],[477.75701904296875,377.58008829752606],[477.17108154296875,378.4576924641927],[476.58514404296875,379.3352966308594]],[[476.58514404296875,379.3352966308594],[475.99920654296875,380.2142028808594],[475.41326904296875,381.0931091308594],[474.82733154296875,381.9720153808594]],[[474.82733154296875,381.9720153808594],[473.94972737630206,382.5579528808594],[473.07212320963544,383.1438903808594],[472.19451904296875,383.7298278808594]],[[472.19451904296875,383.7298278808594],[471.02264404296875,384.3157653808594],[469.85076904296875,384.9017028808594],[468.67889404296875,385.4876403808594]],[[468.67889404296875,385.4876403808594],[467.50701904296875,386.0735778808594],[466.33514404296875,386.6595153808594],[465.16326904296875,387.2454528808594]],[[465.16326904296875,387.2454528808594],[461.64894612630206,387.2454528808594],[458.13462320963544,387.2454528808594],[454.62030029296875,387.2454528808594]],[[454.62030029296875,387.2454528808594],[452.86248779296875,386.6595153808594],[451.10467529296875,386.0735778808594],[449.34686279296875,385.4876403808594]],[[449.34686279296875,385.4876403808594],[447.58905029296875,384.6087341308594],[445.83123779296875,383.7298278808594],[444.07342529296875,382.8509216308594]],[[444.07342529296875,382.8509216308594],[442.31691487630206,382.2649841308594],[440.56040445963544,381.6790466308594],[438.80389404296875,381.0931091308594]],[[438.80389404296875,381.0931091308594],[435.58123779296875,380.8001403808594],[432.35858154296875,380.5071716308594],[429.13592529296875,380.2142028808594]],[[429.13592529296875,380.2142028808594],[426.50050862630206,379.3352966308594],[423.86509195963544,378.4563903808594],[421.22967529296875,377.5774841308594]],[[421.22967529296875,377.5774841308594],[418.88592529296875,376.4069112141927],[416.54217529296875,375.23633829752606],[414.19842529296875,374.0657653808594]],[[414.19842529296875,374.0657653808594],[411.85597737630206,372.0149841308594],[409.51352945963544,369.9642028808594],[407.17108154296875,367.9134216308594]],[[407.17108154296875,367.9134216308594],[405.70623779296875,366.4485778808594],[404.24139404296875,364.9837341308594],[402.77655029296875,363.5188903808594]],[[402.77655029296875,363.5188903808594],[401.89764404296875,362.6412862141927],[401.01873779296875,361.76368204752606],[400.13983154296875,360.8860778808594]],[[400.13983154296875,360.8860778808594],[399.55389404296875,360.0071716308594],[398.96795654296875,359.1282653808594],[398.38201904296875,358.2493591308594]],[[398.38201904296875,358.2493591308594],[397.50441487630206,356.1985778808594],[396.62681070963544,354.1477966308594],[395.74920654296875,352.0970153808594]],[[395.74920654296875,352.0970153808594],[395.17368570963544,350.3743591308594],[394.59816487630206,348.6517028808594],[394.02264404296875,346.9290466308594]],[[394.02264404296875,346.9290466308594],[393.63332112630206,345.7571716308594],[393.24399820963544,344.5852966308594],[392.85467529296875,343.4134216308594]],[[392.85467529296875,343.4134216308594],[391.50441487630206,342.9381612141927],[390.15415445963544,342.46290079752606],[388.80389404296875,341.9876403808594]],[[388.80389404296875,341.9876403808594],[387.04608154296875,341.9876403808594],[385.28826904296875,341.9876403808594],[383.53045654296875,341.9876403808594]],[[383.53045654296875,341.9876403808594],[382.06691487630206,341.6946716308594],[380.60337320963544,341.4017028808594],[379.13983154296875,341.1087341308594]],[[379.13983154296875,341.1087341308594],[377.38201904296875,341.4017028808594],[375.62420654296875,341.6946716308594],[373.86639404296875,341.9876403808594]],[[373.86639404296875,341.9876403808594],[372.40155029296875,342.8665466308594],[370.93670654296875,343.7454528808594],[369.47186279296875,344.6243591308594]],[[369.47186279296875,344.6243591308594],[368.29998779296875,345.5032653808594],[367.12811279296875,346.3821716308594],[365.95623779296875,347.2610778808594]],[[365.95623779296875,347.2610778808594],[365.07863362630206,348.43165079752606],[364.20102945963544,349.6022237141927],[363.32342529296875,350.7727966308594]],[[363.32342529296875,350.7727966308594],[362.44451904296875,351.6517028808594],[361.56561279296875,352.5306091308594],[360.68670654296875,353.4095153808594]],[[360.68670654296875,353.4095153808594],[359.80780029296875,354.2884216308594],[358.92889404296875,355.1673278808594],[358.04998779296875,356.0462341308594]],[[358.04998779296875,356.0462341308594],[357.17108154296875,357.5110778808594],[356.29217529296875,358.9759216308594],[355.41326904296875,360.4407653808594]],[[355.41326904296875,360.4407653808594],[354.53436279296875,361.90430704752606],[353.65545654296875,363.3678487141927],[352.77655029296875,364.8313903808594]],[[352.77655029296875,364.8313903808594],[352.77655029296875,367.1712341308594],[352.77655029296875,369.5110778808594],[352.77655029296875,371.8509216308594]],[[352.77655029296875,371.8509216308594],[353.65545654296875,373.3157653808594],[354.53436279296875,374.7806091308594],[355.41326904296875,376.2454528808594]],[[355.41326904296875,376.2454528808594],[355.99660237630206,377.1204528808594],[356.57993570963544,377.9954528808594],[357.16326904296875,378.8704528808594]],[[357.16326904296875,378.8704528808594],[358.04217529296875,379.74805704752606],[358.92108154296875,380.6256612141927],[359.79998779296875,381.5032653808594]],[[359.79998779296875,381.5032653808594],[361.84946695963544,381.7962341308594],[363.89894612630206,382.0892028808594],[365.94842529296875,382.3821716308594]],[[365.94842529296875,382.3821716308594],[367.41326904296875,382.3821716308594],[368.87811279296875,382.3821716308594],[370.34295654296875,382.3821716308594]],[[370.34295654296875,382.3821716308594],[371.80780029296875,382.3821716308594],[373.27264404296875,382.3821716308594],[374.73748779296875,382.3821716308594]],[[374.73748779296875,382.3821716308594],[376.20102945963544,382.3821716308594],[377.66457112630206,382.3821716308594],[379.12811279296875,382.3821716308594]],[[379.12811279296875,382.3821716308594],[381.47186279296875,382.3821716308594],[383.81561279296875,382.3821716308594],[386.15936279296875,382.3821716308594]],[[386.15936279296875,382.3821716308594],[387.91587320963544,382.9681091308594],[389.67238362630206,383.5540466308594],[391.42889404296875,384.1399841308594]],[[391.42889404296875,384.1399841308594],[394.06561279296875,385.3118591308594],[396.70233154296875,386.4837341308594],[399.33905029296875,387.6556091308594]],[[399.33905029296875,387.6556091308594],[401.68149820963544,388.8274841308594],[404.02394612630206,389.9993591308594],[406.36639404296875,391.1712341308594]],[[406.36639404296875,391.1712341308594],[409.29608154296875,393.51368204752606],[412.22576904296875,395.8561299641927],[415.15545654296875,398.1985778808594]],[[415.15545654296875,398.1985778808594],[416.62030029296875,399.0774841308594],[418.08514404296875,399.9563903808594],[419.54998779296875,400.8352966308594]],[[419.54998779296875,400.8352966308594],[421.01352945963544,402.0071716308594],[422.47707112630206,403.1790466308594],[423.94061279296875,404.3509216308594]],[[423.94061279296875,404.3509216308594],[425.11248779296875,404.9368591308594],[426.28436279296875,405.5227966308594],[427.45623779296875,406.1087341308594]],[[427.45623779296875,406.1087341308594],[428.91977945963544,406.1087341308594],[430.38332112630206,406.1087341308594],[431.84686279296875,406.1087341308594]],[[431.84686279296875,406.1087341308594],[433.01743570963544,406.40040079752606],[434.18800862630206,406.6920674641927],[435.35858154296875,406.9837341308594]],[[435.35858154296875,406.9837341308594],[436.48358154296875,407.56836954752606],[437.60858154296875,408.1530049641927],[438.73358154296875,408.7376403808594]],[[438.73358154296875,408.7376403808594],[439.61248779296875,409.6165466308594],[440.49139404296875,410.4954528808594],[441.37030029296875,411.3743591308594]],[[441.37030029296875,411.3743591308594],[441.37030029296875,413.1321716308594],[441.37030029296875,414.8899841308594],[441.37030029296875,416.6477966308594]],[[441.37030029296875,416.6477966308594],[441.37030029296875,418.4056091308594],[441.37030029296875,420.1634216308594],[441.37030029296875,421.9212341308594]],[[441.37030029296875,421.9212341308594],[441.37030029296875,423.09180704752606],[441.37030029296875,424.2623799641927],[441.37030029296875,425.4329528808594]],[[441.37030029296875,425.4329528808594],[440.49139404296875,426.3118591308594],[439.61248779296875,427.1907653808594],[438.73358154296875,428.0696716308594]],[[438.73358154296875,428.0696716308594],[437.26873779296875,428.6556091308594],[435.80389404296875,429.2415466308594],[434.33905029296875,429.8274841308594]],[[434.33905029296875,429.8274841308594],[432.58253987630206,429.8274841308594],[430.82602945963544,429.8274841308594],[429.06951904296875,429.8274841308594]],[[429.06951904296875,429.8274841308594],[427.60467529296875,429.8274841308594],[426.13983154296875,429.8274841308594],[424.67498779296875,429.8274841308594]],[[424.67498779296875,429.8274841308594],[423.21014404296875,430.4134216308594],[421.74530029296875,430.9993591308594],[420.28045654296875,431.5852966308594]],[[420.28045654296875,431.5852966308594],[416.76613362630206,432.4642028808594],[413.25181070963544,433.3431091308594],[409.73748779296875,434.2220153808594]],[[409.73748779296875,434.2220153808594],[407.10076904296875,435.1009216308594],[404.46405029296875,435.9798278808594],[401.82733154296875,436.8587341308594]],[[401.82733154296875,436.8587341308594],[399.19191487630206,437.1517028808594],[396.55649820963544,437.4446716308594],[393.92108154296875,437.7376403808594]],[[393.92108154296875,437.7376403808594],[390.40675862630206,438.0306091308594],[386.89243570963544,438.3235778808594],[383.37811279296875,438.6165466308594]],[[383.37811279296875,438.6165466308594],[381.32733154296875,438.6165466308594],[379.27655029296875,438.6165466308594],[377.22576904296875,438.6165466308594]],[[377.22576904296875,438.6165466308594],[374.29738362630206,438.6165466308594],[371.36899820963544,438.6165466308594],[368.44061279296875,438.6165466308594]],[[368.44061279296875,438.6165466308594],[365.80389404296875,438.0306091308594],[363.16717529296875,437.4446716308594],[360.53045654296875,436.8587341308594]],[[360.53045654296875,436.8587341308594],[357.89503987630206,435.9798278808594],[355.25962320963544,435.1009216308594],[352.62420654296875,434.2220153808594]],[[352.62420654296875,434.2220153808594],[349.69451904296875,433.0501403808594],[346.76483154296875,431.8782653808594],[343.83514404296875,430.7063903808594]],[[343.83514404296875,430.7063903808594],[339.73488362630206,428.9485778808594],[335.63462320963544,427.1907653808594],[331.53436279296875,425.4329528808594]],[[331.53436279296875,425.4329528808594],[329.19061279296875,424.2623799641927],[326.84686279296875,423.09180704752606],[324.50311279296875,421.9212341308594]],[[324.50311279296875,421.9212341308594],[322.16066487630206,420.7493591308594],[319.81821695963544,419.5774841308594],[317.47576904296875,418.4056091308594]],[[317.47576904296875,418.4056091308594],[314.54608154296875,416.0618591308594],[311.61639404296875,413.7181091308594],[308.68670654296875,411.3743591308594]],[[308.68670654296875,411.3743591308594],[305.75832112630206,408.7389424641927],[302.82993570963544,406.10352579752606],[299.90155029296875,403.4681091308594]],[[299.90155029296875,403.4681091308594],[297.55780029296875,401.1243591308594],[295.21405029296875,398.7806091308594],[292.87030029296875,396.4368591308594]],[[292.87030029296875,396.4368591308594],[289.64894612630206,392.9225362141927],[286.42759195963544,389.40821329752606],[283.20623779296875,385.8938903808594]],[[283.20623779296875,385.8938903808594],[282.03436279296875,383.8431091308594],[280.86248779296875,381.7923278808594],[279.69061279296875,379.7415466308594]],[[279.69061279296875,379.7415466308594],[278.51873779296875,377.1061299641927],[277.34686279296875,374.47071329752606],[276.17498779296875,371.8352966308594]],[[276.17498779296875,371.8352966308594],[276.17498779296875,370.3704528808594],[276.17498779296875,368.9056091308594],[276.17498779296875,367.4407653808594]],[[276.17498779296875,367.4407653808594],[276.17498779296875,366.2688903808594],[276.17498779296875,365.0970153808594],[276.17498779296875,363.9251403808594]],[[276.17498779296875,363.9251403808594],[276.46795654296875,362.1686299641927],[276.76092529296875,360.41211954752606],[277.05389404296875,358.6556091308594]],[[277.05389404296875,358.6556091308594],[277.93280029296875,356.8977966308594],[278.81170654296875,355.1399841308594],[279.69061279296875,353.3821716308594]],[[279.69061279296875,353.3821716308594],[280.56951904296875,352.7962341308594],[281.44842529296875,352.2102966308594],[282.32733154296875,351.6243591308594]],[[282.32733154296875,351.6243591308594],[283.49920654296875,350.4524841308594],[284.67108154296875,349.2806091308594],[285.84295654296875,348.1087341308594]],[[285.84295654296875,348.1087341308594],[286.13592529296875,346.74415079752606],[286.42889404296875,345.3795674641927],[286.72186279296875,344.0149841308594]],[[286.72186279296875,344.0149841308594],[286.13592529296875,343.1360778808594],[285.54998779296875,342.2571716308594],[284.96405029296875,341.3782653808594]],[[284.96405029296875,341.3782653808594],[283.79217529296875,340.4993591308594],[282.62030029296875,339.6204528808594],[281.44842529296875,338.7415466308594]],[[281.44842529296875,338.7415466308594],[279.69061279296875,338.1556091308594],[277.93280029296875,337.5696716308594],[276.17498779296875,336.9837341308594]],[[276.17498779296875,336.9837341308594],[273.24660237630206,336.3977966308594],[270.31821695963544,335.8118591308594],[267.38983154296875,335.2259216308594]],[[267.38983154296875,335.2259216308594],[266.21795654296875,334.9329528808594],[265.04608154296875,334.6399841308594],[263.87420654296875,334.3470153808594]],[[263.87420654296875,334.3470153808594],[262.70233154296875,333.7610778808594],[261.53045654296875,333.1751403808594],[260.35858154296875,332.5892028808594]],[[260.35858154296875,332.5892028808594],[259.18800862630206,332.2962341308594],[258.01743570963544,332.0032653808594],[256.84686279296875,331.7102966308594]],[[256.84686279296875,331.7102966308594],[254.21014404296875,330.8326924641927],[251.57342529296875,329.95508829752606],[248.93670654296875,329.0774841308594]],[[248.93670654296875,329.0774841308594],[247.17889404296875,328.7845153808594],[245.42108154296875,328.4915466308594],[243.66326904296875,328.1985778808594]],[[243.66326904296875,328.1985778808594],[240.7348836263021,327.6126403808594],[237.8064982096354,327.0267028808594],[234.87811279296875,326.4407653808594]],[[234.87811279296875,326.4407653808594],[233.70623779296875,326.4407653808594],[232.53436279296875,326.4407653808594],[231.36248779296875,326.4407653808594]],[[231.36248779296875,326.4407653808594],[229.89764404296875,326.4407653808594],[228.43280029296875,326.4407653808594],[226.96795654296875,326.4407653808594]],[[226.96795654296875,326.4407653808594],[225.2114461263021,326.4407653808594],[223.4549357096354,326.4407653808594],[221.69842529296875,326.4407653808594]],[[221.69842529296875,326.4407653808594],[219.06170654296875,326.7337341308594],[216.42498779296875,327.0267028808594],[213.78826904296875,327.3196716308594]],[[213.78826904296875,327.3196716308594],[212.0317586263021,327.3196716308594],[210.2752482096354,327.3196716308594],[208.51873779296875,327.3196716308594]],[[208.51873779296875,327.3196716308594],[207.34686279296875,327.3196716308594],[206.17498779296875,327.3196716308594],[205.00311279296875,327.3196716308594]],[[205.00311279296875,327.3196716308594],[203.6111857096354,327.3196716308594],[202.2192586263021,327.3196716308594],[200.82733154296875,327.3196716308594]],[[200.82733154296875,327.3196716308594],[199.66717529296875,327.3196716308594],[198.50701904296875,327.3196716308594],[197.34686279296875,327.3196716308594]],[[197.34686279296875,327.3196716308594],[196.25311279296875,327.3196716308594],[195.15936279296875,327.3196716308594],[194.06561279296875,327.3196716308594]],[[194.06561279296875,327.3196716308594],[192.89373779296875,327.3196716308594],[191.72186279296875,327.3196716308594],[190.54998779296875,327.3196716308594]],[[190.54998779296875,327.3196716308594],[188.7934773763021,327.6126403808594],[187.0369669596354,327.9056091308594],[185.28045654296875,328.1985778808594]],[[185.28045654296875,328.1985778808594],[182.35076904296875,328.4915466308594],[179.42108154296875,328.7845153808594],[176.49139404296875,329.0774841308594]],[[176.49139404296875,329.0774841308594],[174.7348836263021,329.0774841308594],[172.9783732096354,329.0774841308594],[171.22186279296875,329.0774841308594]],[[171.22186279296875,329.0774841308594],[169.46405029296875,329.0774841308594],[167.70623779296875,329.0774841308594],[165.94842529296875,329.0774841308594]],[[165.94842529296875,329.0774841308594],[163.60467529296875,329.0774841308594],[161.26092529296875,329.0774841308594],[158.91717529296875,329.0774841308594]],[[158.91717529296875,329.0774841308594],[157.7466023763021,329.0774841308594],[156.5760294596354,329.0774841308594],[155.40545654296875,329.0774841308594]],[[155.40545654296875,329.0774841308594],[154.23358154296875,328.7845153808594],[153.06170654296875,328.4915466308594],[151.88983154296875,328.1985778808594]],[[151.88983154296875,328.1985778808594],[150.7400919596354,327.9056091308594],[149.5903523763021,327.6126403808594],[148.44061279296875,327.3196716308594]],[[148.44061279296875,327.3196716308594],[146.9770711263021,326.7337341308594],[145.5135294596354,326.1477966308594],[144.04998779296875,325.5618591308594]],[[144.04998779296875,325.5618591308594],[142.9380086263021,324.9954528808594],[141.8260294596354,324.4290466308594],[140.71405029296875,323.8626403808594]],[[140.71405029296875,323.8626403808594],[139.83514404296875,322.9850362141927],[138.95623779296875,322.10743204752606],[138.07733154296875,321.2298278808594]],[[138.07733154296875,321.2298278808594],[148.6932169596354,316.2884216308594],[159.3091023763021,311.3470153808594],[169.92498779296875,306.4056091308594]]]} \ No newline at end of file diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_dumps/dump3.json b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_dumps/dump3.json new file mode 100644 index 0000000..fc8a64f --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_dumps/dump3.json @@ -0,0 +1 @@ +{"graph":{"edges":[{"curve":{"p0":{"x":193.47576904296875,"y":236.10092163085938},"p1":{"x":261.65675862630206,"y":236.10092163085938},"p2":{"x":329.8377482096354,"y":236.10092163085938},"p3":{"x":398.01873779296875,"y":236.10092163085938}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[0,1]},{"curve":{"p0":{"x":398.01873779296875,"y":312.4524841308594},"p1":{"x":438.86769612630206,"y":312.4524841308594},"p2":{"x":479.7166544596354,"y":312.4524841308594},"p3":{"x":520.5656127929688,"y":312.4524841308594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[4,5]},{"curve":{"p0":{"x":352.77655029296875,"y":364.8313903808594},"p1":{"x":352.77655029296875,"y":365.2688903808594},"p2":{"x":352.77655029296875,"y":365.7063903808594},"p3":{"x":352.77655029296875,"y":366.1438903808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[84,85]},{"curve":{"p0":{"x":190.13592529296875,"y":302.0110778808594},"p1":{"x":191.24920654296875,"y":301.85198719730903},"p2":{"x":192.36248779296875,"y":301.6928965137587},"p3":{"x":193.47576904296878,"y":301.5338058302084}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[11,12]},{"curve":{"p0":{"x":398.01873779296875,"y":236.10092163085938},"p1":{"x":398.01873779296875,"y":261.5514424641927},"p2":{"x":398.01873779296875,"y":287.001963297526},"p3":{"x":398.01873779296875,"y":312.4524841308593}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[1,4]},{"curve":{"p0":{"x":391.42889404296875,"y":384.1399841308594},"p1":{"x":393.62550862630206,"y":385.1162572790075},"p2":{"x":395.8221232096354,"y":386.0925304271557},"p3":{"x":398.01873779296875,"y":387.06880357530383}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[95,96]},{"curve":{"p0":{"x":520.5656127929688,"y":312.4524841308594},"p1":{"x":520.5656127929688,"y":342.30665079752606},"p2":{"x":520.5656127929688,"y":372.1608174641927},"p3":{"x":520.5656127929688,"y":402.0149841308594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[5,6]},{"curve":{"p0":{"x":419.54998779296875,"y":400.8352966308594},"p1":{"x":420.0410873300058,"y":401.22852579752606},"p2":{"x":420.5321868670428,"y":401.62175496419275},"p3":{"x":421.02328640407984,"y":402.0149841308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[100,101]},{"curve":{"p0":{"x":392.23358154296875,"y":331.8860778808594},"p1":{"x":394.16196695963544,"y":331.8860778808594},"p2":{"x":396.09035237630206,"y":331.8860778808594},"p3":{"x":398.01873779296875,"y":331.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[30,31]},{"curve":{"p0":{"x":398.38201904296875,"y":358.2493591308594},"p1":{"x":397.50441487630206,"y":356.1985778808594},"p2":{"x":396.62681070963544,"y":354.1477966308594},"p3":{"x":395.74920654296875,"y":352.0970153808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[70,71]},{"curve":{"p0":{"x":178.71405029296875,"y":303.7688903808594},"p1":{"x":182.5213419596354,"y":303.1829528808594},"p2":{"x":186.3286336263021,"y":302.5970153808594},"p3":{"x":190.13592529296875,"y":302.0110778808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[10,11]},{"curve":{"p0":{"x":169.92498779296875,"y":306.4056091308594},"p1":{"x":172.85467529296875,"y":305.5267028808594},"p2":{"x":175.78436279296875,"y":304.6477966308594},"p3":{"x":178.71405029296875,"y":303.7688903808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[9,10]},{"curve":{"p0":{"x":197.34686279296875,"y":327.3196716308594},"p1":{"x":196.28729248046875,"y":327.3196716308594},"p2":{"x":195.22772216796875,"y":327.3196716308594},"p3":{"x":194.06561279296875,"y":327.3196716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[159,160]},{"curve":{"p0":{"x":193.47576904296875,"y":301.53380583020845},"p1":{"x":193.47576904296875,"y":279.7228444304254},"p2":{"x":193.47576904296875,"y":257.9118830306424},"p3":{"x":193.47576904296875,"y":236.10092163085938}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[12,0]},{"curve":{"p0":{"x":193.47576904296878,"y":301.5338058302084},"p1":{"x":196.4627482096354,"y":301.1069590137587},"p2":{"x":199.4497273763021,"y":300.68011219730903},"p3":{"x":202.43670654296875,"y":300.2532653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[12,13]},{"curve":{"p0":{"x":202.43670654296875,"y":300.2532653808594},"p1":{"x":206.5369669596354,"y":299.9615987141927},"p2":{"x":210.6372273763021,"y":299.66993204752606},"p3":{"x":214.73748779296875,"y":299.3782653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[13,14]},{"curve":{"p0":{"x":214.73748779296875,"y":299.3782653808594},"p1":{"x":218.8377482096354,"y":299.0852966308594},"p2":{"x":222.9380086263021,"y":298.7923278808594},"p3":{"x":227.03826904296875,"y":298.4993591308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[14,15]},{"curve":{"p0":{"x":227.03826904296875,"y":298.4993591308594},"p1":{"x":230.84686279296875,"y":298.4993591308594},"p2":{"x":234.65545654296875,"y":298.4993591308594},"p3":{"x":238.46405029296875,"y":298.4993591308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[15,16]},{"curve":{"p0":{"x":238.46405029296875,"y":298.4993591308594},"p1":{"x":242.2713419596354,"y":298.4993591308594},"p2":{"x":246.0786336263021,"y":298.4993591308594},"p3":{"x":249.88592529296875,"y":298.4993591308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[16,17]},{"curve":{"p0":{"x":249.88592529296875,"y":298.4993591308594},"p1":{"x":255.4510294596354,"y":298.7923278808594},"p2":{"x":261.01613362630206,"y":299.0852966308594},"p3":{"x":266.58123779296875,"y":299.3782653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[17,18]},{"curve":{"p0":{"x":266.58123779296875,"y":299.3782653808594},"p1":{"x":270.68149820963544,"y":299.3782653808594},"p2":{"x":274.78175862630206,"y":299.3782653808594},"p3":{"x":278.88201904296875,"y":299.3782653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[18,19]},{"curve":{"p0":{"x":278.88201904296875,"y":299.3782653808594},"p1":{"x":282.68931070963544,"y":299.66993204752606},"p2":{"x":286.49660237630206,"y":299.9615987141927},"p3":{"x":290.30389404296875,"y":300.2532653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[19,20]},{"curve":{"p0":{"x":290.30389404296875,"y":300.2532653808594},"p1":{"x":296.74790445963544,"y":301.4251403808594},"p2":{"x":303.19191487630206,"y":302.5970153808594},"p3":{"x":309.63592529296875,"y":303.7688903808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[20,21]},{"curve":{"p0":{"x":309.63592529296875,"y":303.7688903808594},"p1":{"x":314.02915445963544,"y":305.2337341308594},"p2":{"x":318.42238362630206,"y":306.6985778808594},"p3":{"x":322.81561279296875,"y":308.1634216308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[21,22]},{"curve":{"p0":{"x":322.81561279296875,"y":308.1634216308594},"p1":{"x":326.33123779296875,"y":309.6282653808594},"p2":{"x":329.84686279296875,"y":311.0931091308594},"p3":{"x":333.36248779296875,"y":312.5579528808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[22,23]},{"curve":{"p0":{"x":333.36248779296875,"y":312.5579528808594},"p1":{"x":336.29087320963544,"y":313.72852579752606},"p2":{"x":339.21925862630206,"y":314.8990987141927},"p3":{"x":342.14764404296875,"y":316.0696716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[23,24]},{"curve":{"p0":{"x":342.14764404296875,"y":316.0696716308594},"p1":{"x":345.07602945963544,"y":317.2415466308594},"p2":{"x":348.00441487630206,"y":318.4134216308594},"p3":{"x":350.93280029296875,"y":319.5852966308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[24,25]},{"curve":{"p0":{"x":350.93280029296875,"y":319.5852966308594},"p1":{"x":353.56951904296875,"y":320.1712341308594},"p2":{"x":356.20623779296875,"y":320.7571716308594},"p3":{"x":358.84295654296875,"y":321.3431091308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[25,26]},{"curve":{"p0":{"x":358.84295654296875,"y":321.3431091308594},"p1":{"x":361.77134195963544,"y":322.5149841308594},"p2":{"x":364.69972737630206,"y":323.6868591308594},"p3":{"x":367.62811279296875,"y":324.8587341308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[26,27]},{"curve":{"p0":{"x":367.62811279296875,"y":324.8587341308594},"p1":{"x":370.55780029296875,"y":326.0306091308594},"p2":{"x":373.48748779296875,"y":327.2024841308594},"p3":{"x":376.41717529296875,"y":328.3743591308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[27,28]},{"curve":{"p0":{"x":376.41717529296875,"y":328.3743591308594},"p1":{"x":379.05259195963544,"y":329.25196329752606},"p2":{"x":381.68800862630206,"y":330.1295674641927},"p3":{"x":384.32342529296875,"y":331.0071716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[28,29]},{"curve":{"p0":{"x":384.32342529296875,"y":331.0071716308594},"p1":{"x":386.96014404296875,"y":331.3001403808594},"p2":{"x":389.59686279296875,"y":331.5931091308594},"p3":{"x":392.23358154296875,"y":331.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[29,30]},{"curve":{"p0":{"x":398.01873779296875,"y":312.4524841308593},"p1":{"x":398.01873779296875,"y":318.93034871419263},"p2":{"x":398.01873779296875,"y":325.408213297526},"p3":{"x":398.01873779296875,"y":331.8860778808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[4,31]},{"curve":{"p0":{"x":398.01873779296875,"y":331.8860778808594},"p1":{"x":398.01873779296875,"y":334.4092140197754},"p2":{"x":398.01873779296875,"y":336.9323501586914},"p3":{"x":398.01873779296875,"y":339.4554862976074}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[31,8]},{"curve":{"p0":{"x":400.13983154296875,"y":331.8860778808594},"p1":{"x":402.48358154296875,"y":331.8860778808594},"p2":{"x":404.82733154296875,"y":331.8860778808594},"p3":{"x":407.17108154296875,"y":331.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[32,33]},{"curve":{"p0":{"x":398.01873779296875,"y":331.8860778808594},"p1":{"x":398.692626953125,"y":331.8860778808594},"p2":{"x":399.36651611328125,"y":331.8860778808594},"p3":{"x":400.13983154296875,"y":331.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[31,32]},{"curve":{"p0":{"x":400.13983154296875,"y":331.8860778808594},"p1":{"x":400.07354736328125,"y":331.8860778808594},"p2":{"x":400.106689453125,"y":331.8860778808594},"p3":{"x":400.13983154296875,"y":331.8860778808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[32,32]},{"curve":{"p0":{"x":407.17108154296875,"y":331.8860778808594},"p1":{"x":410.09946695963544,"y":331.8860778808594},"p2":{"x":413.02785237630206,"y":331.8860778808594},"p3":{"x":415.95623779296875,"y":331.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[33,34]},{"curve":{"p0":{"x":430.01483154296875,"y":331.8860778808594},"p1":{"x":432.65155029296875,"y":331.8860778808594},"p2":{"x":435.28826904296875,"y":331.8860778808594},"p3":{"x":437.92498779296875,"y":331.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[36,37]},{"curve":{"p0":{"x":415.95623779296875,"y":331.8860778808594},"p1":{"x":417.9749755859375,"y":331.8860778808594},"p2":{"x":419.99371337890625,"y":331.8860778808594},"p3":{"x":422.10858154296875,"y":331.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[34,35]},{"curve":{"p0":{"x":422.10858154296875,"y":331.8860778808594},"p1":{"x":422.04449462890625,"y":331.8860778808594},"p2":{"x":422.0765380859375,"y":331.8860778808594},"p3":{"x":422.10858154296875,"y":331.8860778808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[35,35]},{"curve":{"p0":{"x":422.10858154296875,"y":331.8860778808594},"p1":{"x":424.70281982421875,"y":331.8860778808594},"p2":{"x":427.2970581054687,"y":331.8860778808594},"p3":{"x":430.01483154296875,"y":331.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[35,36]},{"curve":{"p0":{"x":430.01483154296875,"y":331.8860778808594},"p1":{"x":429.9324747721354,"y":331.8860778808594},"p2":{"x":429.97365315755206,"y":331.8860778808594},"p3":{"x":430.01483154296875,"y":331.8860778808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[36,36]},{"curve":{"p0":{"x":437.92498779296875,"y":331.8860778808594},"p1":{"x":440.26743570963544,"y":331.5931091308594},"p2":{"x":442.60988362630206,"y":331.3001403808594},"p3":{"x":444.95233154296875,"y":331.0071716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[37,38]},{"curve":{"p0":{"x":444.95233154296875,"y":331.0071716308594},"p1":{"x":446.41717529296875,"y":331.0071716308594},"p2":{"x":447.88201904296875,"y":331.0071716308594},"p3":{"x":449.34686279296875,"y":331.0071716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[38,39]},{"curve":{"p0":{"x":449.34686279296875,"y":331.0071716308594},"p1":{"x":450.81170654296875,"y":330.7155049641927},"p2":{"x":452.27655029296875,"y":330.42383829752606},"p3":{"x":453.74139404296875,"y":330.1321716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[39,40]},{"curve":{"p0":{"x":459.88983154296875,"y":330.1321716308594},"p1":{"x":461.06170654296875,"y":330.1321716308594},"p2":{"x":462.23358154296875,"y":330.1321716308594},"p3":{"x":463.40545654296875,"y":330.1321716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[41,42]},{"curve":{"p0":{"x":453.74139404296875,"y":330.1321716308594},"p1":{"x":455.75885009765625,"y":330.1321716308594},"p2":{"x":457.7763061523437,"y":330.1321716308594},"p3":{"x":459.88983154296875,"y":330.1321716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[40,41]},{"curve":{"p0":{"x":459.88983154296875,"y":330.1321716308594},"p1":{"x":459.8257853190104,"y":330.1321716308594},"p2":{"x":459.85780843098956,"y":330.1321716308594},"p3":{"x":459.88983154296875,"y":330.1321716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[41,41]},{"curve":{"p0":{"x":463.40545654296875,"y":330.1321716308594},"p1":{"x":464.87030029296875,"y":330.42383829752606},"p2":{"x":466.33514404296875,"y":330.7155049641927},"p3":{"x":467.79998779296875,"y":331.0071716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[42,43]},{"curve":{"p0":{"x":467.79998779296875,"y":331.0071716308594},"p1":{"x":468.97186279296875,"y":331.5931091308594},"p2":{"x":470.14373779296875,"y":332.1790466308594},"p3":{"x":471.31561279296875,"y":332.7649841308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[43,44]},{"curve":{"p0":{"x":471.31561279296875,"y":332.7649841308594},"p1":{"x":473.36509195963544,"y":334.8157653808594},"p2":{"x":475.41457112630206,"y":336.8665466308594},"p3":{"x":477.46405029296875,"y":338.9173278808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[44,45]},{"curve":{"p0":{"x":479.22186279296875,"y":342.4329528808594},"p1":{"x":479.80780029296875,"y":343.6048278808594},"p2":{"x":480.39373779296875,"y":344.7767028808594},"p3":{"x":480.97967529296875,"y":345.9485778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[46,47]},{"curve":{"p0":{"x":477.46405029296875,"y":338.9173278808594},"p1":{"x":478.03167724609375,"y":340.0525817871094},"p2":{"x":478.59930419921875,"y":341.1878356933594},"p3":{"x":479.22186279296875,"y":342.4329528808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[45,46]},{"curve":{"p0":{"x":479.22186279296875,"y":342.4329528808594},"p1":{"x":479.18524169921875,"y":342.3597106933594},"p2":{"x":479.20355224609375,"y":342.3963317871094},"p3":{"x":479.22186279296875,"y":342.4329528808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[46,46]},{"curve":{"p0":{"x":480.97967529296875,"y":345.9485778808594},"p1":{"x":481.27264404296875,"y":347.70508829752606},"p2":{"x":481.56561279296875,"y":349.4615987141927},"p3":{"x":481.85858154296875,"y":351.2181091308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[47,48]},{"curve":{"p0":{"x":481.85858154296875,"y":351.2181091308594},"p1":{"x":482.15155029296875,"y":352.6829528808594},"p2":{"x":482.44451904296875,"y":354.1477966308594},"p3":{"x":482.73748779296875,"y":355.6126403808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[48,49]},{"curve":{"p0":{"x":482.73748779296875,"y":355.6126403808594},"p1":{"x":483.03045654296875,"y":356.7845153808594},"p2":{"x":483.32342529296875,"y":357.9563903808594},"p3":{"x":483.61639404296875,"y":359.1282653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[49,50]},{"curve":{"p0":{"x":483.61639404296875,"y":359.1282653808594},"p1":{"x":483.61639404296875,"y":360.88477579752606},"p2":{"x":483.61639404296875,"y":362.6412862141927},"p3":{"x":483.61639404296875,"y":364.3977966308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[50,51]},{"curve":{"p0":{"x":483.61639404296875,"y":364.3977966308594},"p1":{"x":483.32342529296875,"y":365.5696716308594},"p2":{"x":483.03045654296875,"y":366.7415466308594},"p3":{"x":482.73748779296875,"y":367.9134216308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[51,52]},{"curve":{"p0":{"x":482.73748779296875,"y":367.9134216308594},"p1":{"x":481.85858154296875,"y":369.9642028808594},"p2":{"x":480.97967529296875,"y":372.0149841308594},"p3":{"x":480.10076904296875,"y":374.0657653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[52,53]},{"curve":{"p0":{"x":480.10076904296875,"y":374.0657653808594},"p1":{"x":479.51483154296875,"y":374.9446716308594},"p2":{"x":478.92889404296875,"y":375.8235778808594},"p3":{"x":478.34295654296875,"y":376.7024841308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[53,54]},{"curve":{"p0":{"x":478.34295654296875,"y":376.7024841308594},"p1":{"x":477.75701904296875,"y":377.58008829752606},"p2":{"x":477.17108154296875,"y":378.4576924641927},"p3":{"x":476.58514404296875,"y":379.3352966308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[54,55]},{"curve":{"p0":{"x":476.58514404296875,"y":379.3352966308594},"p1":{"x":475.99920654296875,"y":380.2142028808594},"p2":{"x":475.41326904296875,"y":381.0931091308594},"p3":{"x":474.82733154296875,"y":381.9720153808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[55,56]},{"curve":{"p0":{"x":474.82733154296875,"y":381.9720153808594},"p1":{"x":473.94972737630206,"y":382.5579528808594},"p2":{"x":473.07212320963544,"y":383.1438903808594},"p3":{"x":472.19451904296875,"y":383.7298278808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[56,57]},{"curve":{"p0":{"x":468.67889404296875,"y":385.4876403808594},"p1":{"x":467.50701904296875,"y":386.0735778808594},"p2":{"x":466.33514404296875,"y":386.6595153808594},"p3":{"x":465.16326904296875,"y":387.2454528808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[58,59]},{"curve":{"p0":{"x":472.19451904296875,"y":383.7298278808594},"p1":{"x":471.05926513671875,"y":384.2974548339844},"p2":{"x":469.92401123046875,"y":384.8650817871094},"p3":{"x":468.67889404296875,"y":385.4876403808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[57,58]},{"curve":{"p0":{"x":468.67889404296875,"y":385.4876403808594},"p1":{"x":468.75213623046875,"y":385.4510192871094},"p2":{"x":468.71551513671875,"y":385.4693298339844},"p3":{"x":468.67889404296875,"y":385.4876403808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[58,58]},{"curve":{"p0":{"x":465.16326904296875,"y":387.2454528808594},"p1":{"x":461.64894612630206,"y":387.2454528808594},"p2":{"x":458.13462320963544,"y":387.2454528808594},"p3":{"x":454.62030029296875,"y":387.2454528808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[59,60]},{"curve":{"p0":{"x":454.62030029296875,"y":387.2454528808594},"p1":{"x":452.86248779296875,"y":386.6595153808594},"p2":{"x":451.10467529296875,"y":386.0735778808594},"p3":{"x":449.34686279296875,"y":385.4876403808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[60,61]},{"curve":{"p0":{"x":449.34686279296875,"y":385.4876403808594},"p1":{"x":447.58905029296875,"y":384.6087341308594},"p2":{"x":445.83123779296875,"y":383.7298278808594},"p3":{"x":444.07342529296875,"y":382.8509216308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[61,62]},{"curve":{"p0":{"x":444.07342529296875,"y":382.8509216308594},"p1":{"x":442.31691487630206,"y":382.2649841308594},"p2":{"x":440.56040445963544,"y":381.6790466308594},"p3":{"x":438.80389404296875,"y":381.0931091308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[62,63]},{"curve":{"p0":{"x":438.80389404296875,"y":381.0931091308594},"p1":{"x":435.58123779296875,"y":380.8001403808594},"p2":{"x":432.35858154296875,"y":380.5071716308594},"p3":{"x":429.13592529296875,"y":380.2142028808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[63,64]},{"curve":{"p0":{"x":429.13592529296875,"y":380.2142028808594},"p1":{"x":426.50050862630206,"y":379.3352966308594},"p2":{"x":423.86509195963544,"y":378.4563903808594},"p3":{"x":421.22967529296875,"y":377.5774841308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[64,65]},{"curve":{"p0":{"x":421.22967529296875,"y":377.5774841308594},"p1":{"x":418.88592529296875,"y":376.4069112141927},"p2":{"x":416.54217529296875,"y":375.23633829752606},"p3":{"x":414.19842529296875,"y":374.0657653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[65,66]},{"curve":{"p0":{"x":414.19842529296875,"y":374.0657653808594},"p1":{"x":411.85597737630206,"y":372.0149841308594},"p2":{"x":409.51352945963544,"y":369.9642028808594},"p3":{"x":407.17108154296875,"y":367.9134216308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[66,67]},{"curve":{"p0":{"x":407.17108154296875,"y":367.9134216308594},"p1":{"x":405.70623779296875,"y":366.4485778808594},"p2":{"x":404.24139404296875,"y":364.9837341308594},"p3":{"x":402.77655029296875,"y":363.5188903808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[67,68]},{"curve":{"p0":{"x":402.77655029296875,"y":363.5188903808594},"p1":{"x":401.89764404296875,"y":362.6412862141927},"p2":{"x":401.01873779296875,"y":361.76368204752606},"p3":{"x":400.13983154296875,"y":360.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[68,69]},{"curve":{"p0":{"x":400.13983154296875,"y":360.8860778808594},"p1":{"x":399.55389404296875,"y":360.0071716308594},"p2":{"x":398.96795654296875,"y":359.1282653808594},"p3":{"x":398.38201904296875,"y":358.2493591308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[69,70]},{"curve":{"p0":{"x":398.01873779296875,"y":339.4554862976074},"p1":{"x":398.01873779296875,"y":345.43713927528387},"p2":{"x":398.01873779296875,"y":351.4187922529603},"p3":{"x":398.38201904296875,"y":358.2493591308594}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[8,70]},{"curve":{"p0":{"x":398.38201904296875,"y":358.2493591308594},"p1":{"x":398.01873779296875,"y":360.31492694737767},"p2":{"x":398.01873779296875,"y":363.2294086641185},"p3":{"x":398.01873779296875,"y":366.1438903808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[70,2]},{"curve":{"p0":{"x":395.74920654296875,"y":352.0970153808594},"p1":{"x":395.17368570963544,"y":350.3743591308594},"p2":{"x":394.59816487630206,"y":348.6517028808594},"p3":{"x":394.02264404296875,"y":346.9290466308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[71,72]},{"curve":{"p0":{"x":394.02264404296875,"y":346.9290466308594},"p1":{"x":393.63332112630206,"y":345.7571716308594},"p2":{"x":393.24399820963544,"y":344.5852966308594},"p3":{"x":392.85467529296875,"y":343.4134216308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[72,73]},{"curve":{"p0":{"x":392.85467529296875,"y":343.4134216308594},"p1":{"x":391.50441487630206,"y":342.9381612141927},"p2":{"x":390.15415445963544,"y":342.46290079752606},"p3":{"x":388.80389404296875,"y":341.9876403808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[73,74]},{"curve":{"p0":{"x":388.80389404296875,"y":341.9876403808594},"p1":{"x":387.04608154296875,"y":341.9876403808594},"p2":{"x":385.28826904296875,"y":341.9876403808594},"p3":{"x":383.53045654296875,"y":341.9876403808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[74,75]},{"curve":{"p0":{"x":383.53045654296875,"y":341.9876403808594},"p1":{"x":382.06691487630206,"y":341.6946716308594},"p2":{"x":380.60337320963544,"y":341.4017028808594},"p3":{"x":379.13983154296875,"y":341.1087341308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[75,76]},{"curve":{"p0":{"x":379.13983154296875,"y":341.1087341308594},"p1":{"x":377.38201904296875,"y":341.4017028808594},"p2":{"x":375.62420654296875,"y":341.6946716308594},"p3":{"x":373.86639404296875,"y":341.9876403808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[76,77]},{"curve":{"p0":{"x":373.86639404296875,"y":341.9876403808594},"p1":{"x":372.40155029296875,"y":342.8665466308594},"p2":{"x":370.93670654296875,"y":343.7454528808594},"p3":{"x":369.47186279296875,"y":344.6243591308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[77,78]},{"curve":{"p0":{"x":369.47186279296875,"y":344.6243591308594},"p1":{"x":368.29998779296875,"y":345.5032653808594},"p2":{"x":367.12811279296875,"y":346.3821716308594},"p3":{"x":365.95623779296875,"y":347.2610778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[78,79]},{"curve":{"p0":{"x":365.95623779296875,"y":347.2610778808594},"p1":{"x":365.07863362630206,"y":348.43165079752606},"p2":{"x":364.20102945963544,"y":349.6022237141927},"p3":{"x":363.32342529296875,"y":350.7727966308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[79,80]},{"curve":{"p0":{"x":360.68670654296875,"y":353.4095153808594},"p1":{"x":359.80780029296875,"y":354.2884216308594},"p2":{"x":358.92889404296875,"y":355.1673278808594},"p3":{"x":358.04998779296875,"y":356.0462341308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[81,82]},{"curve":{"p0":{"x":363.32342529296875,"y":350.7727966308594},"p1":{"x":362.47198486328125,"y":351.6242370605469},"p2":{"x":361.62054443359375,"y":352.4756774902344},"p3":{"x":360.68670654296875,"y":353.4095153808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[80,81]},{"curve":{"p0":{"x":360.68670654296875,"y":353.4095153808594},"p1":{"x":360.74163818359375,"y":353.3545837402344},"p2":{"x":360.71417236328125,"y":353.3820495605469},"p3":{"x":360.68670654296875,"y":353.4095153808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[81,81]},{"curve":{"p0":{"x":360.68670654296875,"y":353.4095153808594},"p1":{"x":360.70428466796875,"y":353.3919372558594},"p2":{"x":360.69549560546875,"y":353.4007263183594},"p3":{"x":360.68670654296875,"y":353.4095153808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[81,81]},{"curve":{"p0":{"x":360.68670654296875,"y":353.4095153808594},"p1":{"x":360.82534691595265,"y":353.2708750078755},"p2":{"x":360.7692103232107,"y":353.3270116006174},"p3":{"x":360.68670654296875,"y":353.4095153808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[81,81]},{"curve":{"p0":{"x":358.04998779296875,"y":356.0462341308594},"p1":{"x":357.17108154296875,"y":357.5110778808594},"p2":{"x":356.29217529296875,"y":358.9759216308594},"p3":{"x":355.41326904296875,"y":360.4407653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[82,83]},{"curve":{"p0":{"x":355.41326904296875,"y":360.4407653808594},"p1":{"x":354.53436279296875,"y":361.90430704752606},"p2":{"x":353.65545654296875,"y":363.3678487141927},"p3":{"x":352.77655029296875,"y":364.8313903808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[83,84]},{"curve":{"p0":{"x":398.01873779296875,"y":366.1438903808594},"p1":{"x":382.93800862630206,"y":366.1438903808594},"p2":{"x":367.8572794596354,"y":366.1438903808594},"p3":{"x":352.7765502929687,"y":366.1438903808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[2,85]},{"curve":{"p0":{"x":276.17498779296875,"y":371.8352966308594},"p1":{"x":276.17498779296875,"y":370.3933410644531},"p2":{"x":276.17498779296875,"y":368.9513854980469},"p3":{"x":276.17498779296875,"y":367.4407653808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[133,134]},{"curve":{"p0":{"x":352.77655029296875,"y":366.1438903808594},"p1":{"x":352.77655029296875,"y":368.0462341308594},"p2":{"x":352.77655029296875,"y":369.9485778808594},"p3":{"x":352.77655029296875,"y":371.8509216308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[85,86]},{"curve":{"p0":{"x":352.77655029296875,"y":371.8509216308594},"p1":{"x":353.65545654296875,"y":373.3157653808594},"p2":{"x":354.53436279296875,"y":374.7806091308594},"p3":{"x":355.41326904296875,"y":376.2454528808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[86,87]},{"curve":{"p0":{"x":355.41326904296875,"y":376.2454528808594},"p1":{"x":355.99660237630206,"y":377.1204528808594},"p2":{"x":356.57993570963544,"y":377.9954528808594},"p3":{"x":357.16326904296875,"y":378.8704528808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[87,88]},{"curve":{"p0":{"x":357.16326904296875,"y":378.8704528808594},"p1":{"x":358.04217529296875,"y":379.74805704752606},"p2":{"x":358.92108154296875,"y":380.6256612141927},"p3":{"x":359.79998779296875,"y":381.5032653808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[88,89]},{"curve":{"p0":{"x":359.79998779296875,"y":381.5032653808594},"p1":{"x":361.84946695963544,"y":381.7962341308594},"p2":{"x":363.89894612630206,"y":382.0892028808594},"p3":{"x":365.94842529296875,"y":382.3821716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[89,90]},{"curve":{"p0":{"x":379.12811279296875,"y":382.3821716308594},"p1":{"x":381.47186279296875,"y":382.3821716308594},"p2":{"x":383.81561279296875,"y":382.3821716308594},"p3":{"x":386.15936279296875,"y":382.3821716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[93,94]},{"curve":{"p0":{"x":365.94842529296875,"y":382.3821716308594},"p1":{"x":367.390380859375,"y":382.3821716308594},"p2":{"x":368.83233642578125,"y":382.3821716308594},"p3":{"x":370.34295654296875,"y":382.3821716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[90,91]},{"curve":{"p0":{"x":370.34295654296875,"y":382.3821716308594},"p1":{"x":370.29718017578125,"y":382.3821716308594},"p2":{"x":370.320068359375,"y":382.3821716308594},"p3":{"x":370.34295654296875,"y":382.3821716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[91,91]},{"curve":{"p0":{"x":370.34295654296875,"y":382.3821716308594},"p1":{"x":371.784912109375,"y":382.3821716308594},"p2":{"x":373.22686767578125,"y":382.3821716308594},"p3":{"x":374.73748779296875,"y":382.3821716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[91,92]},{"curve":{"p0":{"x":374.73748779296875,"y":382.3821716308594},"p1":{"x":374.69171142578125,"y":382.3821716308594},"p2":{"x":374.714599609375,"y":382.3821716308594},"p3":{"x":374.73748779296875,"y":382.3821716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[92,92]},{"curve":{"p0":{"x":374.73748779296875,"y":382.3821716308594},"p1":{"x":376.17816162109375,"y":382.3821716308594},"p2":{"x":377.6188354492187,"y":382.3821716308594},"p3":{"x":379.12811279296875,"y":382.3821716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[92,93]},{"curve":{"p0":{"x":379.12811279296875,"y":382.3821716308594},"p1":{"x":379.0823771158854,"y":382.3821716308594},"p2":{"x":379.10524495442706,"y":382.3821716308594},"p3":{"x":379.12811279296875,"y":382.3821716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[93,93]},{"curve":{"p0":{"x":386.15936279296875,"y":382.3821716308594},"p1":{"x":387.91587320963544,"y":382.9681091308594},"p2":{"x":389.67238362630206,"y":383.5540466308594},"p3":{"x":391.42889404296875,"y":384.1399841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[94,95]},{"curve":{"p0":{"x":398.01873779296875,"y":402.0149841308594},"p1":{"x":398.01873779296875,"y":397.0329239456742},"p2":{"x":398.01873779296875,"y":392.05086376048905},"p3":{"x":398.01873779296875,"y":387.0688035753039}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[7,96]},{"curve":{"p0":{"x":398.01873779296875,"y":387.0688035753039},"p1":{"x":398.01873779296875,"y":380.09383251048905},"p2":{"x":398.01873779296875,"y":373.1188614456742},"p3":{"x":398.01873779296875,"y":366.1438903808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[96,2]},{"curve":{"p0":{"x":398.01873779296875,"y":387.06880357530383},"p1":{"x":398.4588419596354,"y":387.2644054271557},"p2":{"x":398.89894612630206,"y":387.4600072790075},"p3":{"x":399.33905029296875,"y":387.6556091308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[96,97]},{"curve":{"p0":{"x":399.33905029296875,"y":387.6556091308594},"p1":{"x":401.68149820963544,"y":388.8274841308594},"p2":{"x":404.02394612630206,"y":389.9993591308594},"p3":{"x":406.36639404296875,"y":391.1712341308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[97,98]},{"curve":{"p0":{"x":406.36639404296875,"y":391.1712341308594},"p1":{"x":409.29608154296875,"y":393.51368204752606},"p2":{"x":412.22576904296875,"y":395.8561299641927},"p3":{"x":415.15545654296875,"y":398.1985778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[98,99]},{"curve":{"p0":{"x":415.15545654296875,"y":398.1985778808594},"p1":{"x":416.62030029296875,"y":399.0774841308594},"p2":{"x":418.08514404296875,"y":399.9563903808594},"p3":{"x":419.54998779296875,"y":400.8352966308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[99,100]},{"curve":{"p0":{"x":520.5656127929688,"y":402.0149841308594},"p1":{"x":487.3848373300058,"y":402.0149841308594},"p2":{"x":454.2040618670428,"y":402.0149841308594},"p3":{"x":421.02328640407984,"y":402.0149841308594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[6,101]},{"curve":{"p0":{"x":421.02328640407984,"y":402.0149841308594},"p1":{"x":413.3551035337095,"y":402.0149841308594},"p2":{"x":405.68692066333915,"y":402.0149841308594},"p3":{"x":398.01873779296875,"y":402.0149841308594}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[101,7]},{"curve":{"p0":{"x":421.02328640407984,"y":402.0149841308594},"p1":{"x":421.9957285337095,"y":402.79362996419275},"p2":{"x":422.9681706633391,"y":403.57227579752606},"p3":{"x":423.94061279296875,"y":404.3509216308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[101,102]},{"curve":{"p0":{"x":423.94061279296875,"y":404.3509216308594},"p1":{"x":425.11248779296875,"y":404.9368591308594},"p2":{"x":426.28436279296875,"y":405.5227966308594},"p3":{"x":427.45623779296875,"y":406.1087341308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[102,103]},{"curve":{"p0":{"x":427.45623779296875,"y":406.1087341308594},"p1":{"x":428.91977945963544,"y":406.1087341308594},"p2":{"x":430.38332112630206,"y":406.1087341308594},"p3":{"x":431.84686279296875,"y":406.1087341308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[103,104]},{"curve":{"p0":{"x":431.84686279296875,"y":406.1087341308594},"p1":{"x":433.01743570963544,"y":406.40040079752606},"p2":{"x":434.18800862630206,"y":406.6920674641927},"p3":{"x":435.35858154296875,"y":406.9837341308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[104,105]},{"curve":{"p0":{"x":435.35858154296875,"y":406.9837341308594},"p1":{"x":436.48358154296875,"y":407.56836954752606},"p2":{"x":437.60858154296875,"y":408.1530049641927},"p3":{"x":438.73358154296875,"y":408.7376403808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[105,106]},{"curve":{"p0":{"x":438.73358154296875,"y":408.7376403808594},"p1":{"x":439.61248779296875,"y":409.6165466308594},"p2":{"x":440.49139404296875,"y":410.4954528808594},"p3":{"x":441.37030029296875,"y":411.3743591308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[106,107]},{"curve":{"p0":{"x":441.37030029296875,"y":421.9212341308594},"p1":{"x":441.37030029296875,"y":423.09180704752606},"p2":{"x":441.37030029296875,"y":424.2623799641927},"p3":{"x":441.37030029296875,"y":425.4329528808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[109,110]},{"curve":{"p0":{"x":441.37030029296875,"y":411.3743591308594},"p1":{"x":441.37030029296875,"y":413.1047058105469},"p2":{"x":441.37030029296875,"y":414.8350524902344},"p3":{"x":441.37030029296875,"y":416.6477966308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[107,108]},{"curve":{"p0":{"x":441.37030029296875,"y":416.6477966308594},"p1":{"x":441.37030029296875,"y":416.5928649902344},"p2":{"x":441.37030029296875,"y":416.6203308105469},"p3":{"x":441.37030029296875,"y":416.6477966308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[108,108]},{"curve":{"p0":{"x":441.37030029296875,"y":416.6477966308594},"p1":{"x":441.37030029296875,"y":418.3781433105469},"p2":{"x":441.37030029296875,"y":420.1084899902344},"p3":{"x":441.37030029296875,"y":421.9212341308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[108,109]},{"curve":{"p0":{"x":441.37030029296875,"y":421.9212341308594},"p1":{"x":441.37030029296875,"y":421.8663024902344},"p2":{"x":441.37030029296875,"y":421.8937683105469},"p3":{"x":441.37030029296875,"y":421.9212341308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[109,109]},{"curve":{"p0":{"x":441.37030029296875,"y":425.4329528808594},"p1":{"x":440.49139404296875,"y":426.3118591308594},"p2":{"x":439.61248779296875,"y":427.1907653808594},"p3":{"x":438.73358154296875,"y":428.0696716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[110,111]},{"curve":{"p0":{"x":438.73358154296875,"y":428.0696716308594},"p1":{"x":437.26873779296875,"y":428.6556091308594},"p2":{"x":435.80389404296875,"y":429.2415466308594},"p3":{"x":434.33905029296875,"y":429.8274841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[111,112]},{"curve":{"p0":{"x":429.06951904296875,"y":429.8274841308594},"p1":{"x":427.60467529296875,"y":429.8274841308594},"p2":{"x":426.13983154296875,"y":429.8274841308594},"p3":{"x":424.67498779296875,"y":429.8274841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[113,114]},{"curve":{"p0":{"x":434.33905029296875,"y":429.8274841308594},"p1":{"x":432.6099853515625,"y":429.8274841308594},"p2":{"x":430.8809204101563,"y":429.8274841308594},"p3":{"x":429.06951904296875,"y":429.8274841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[112,113]},{"curve":{"p0":{"x":429.06951904296875,"y":429.8274841308594},"p1":{"x":429.1244099934896,"y":429.8274841308594},"p2":{"x":429.0969645182292,"y":429.8274841308594},"p3":{"x":429.06951904296875,"y":429.8274841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[113,113]},{"curve":{"p0":{"x":424.67498779296875,"y":429.8274841308594},"p1":{"x":423.21014404296875,"y":430.4134216308594},"p2":{"x":421.74530029296875,"y":430.9993591308594},"p3":{"x":420.28045654296875,"y":431.5852966308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[114,115]},{"curve":{"p0":{"x":420.28045654296875,"y":431.5852966308594},"p1":{"x":416.76613362630206,"y":432.4642028808594},"p2":{"x":413.25181070963544,"y":433.3431091308594},"p3":{"x":409.73748779296875,"y":434.2220153808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[115,116]},{"curve":{"p0":{"x":409.73748779296875,"y":434.2220153808594},"p1":{"x":407.10076904296875,"y":435.1009216308594},"p2":{"x":404.46405029296875,"y":435.9798278808594},"p3":{"x":401.82733154296875,"y":436.8587341308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[116,117]},{"curve":{"p0":{"x":401.82733154296875,"y":436.8587341308594},"p1":{"x":399.19191487630206,"y":437.1517028808594},"p2":{"x":396.55649820963544,"y":437.4446716308594},"p3":{"x":393.92108154296875,"y":437.7376403808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[117,118]},{"curve":{"p0":{"x":393.92108154296875,"y":437.7376403808594},"p1":{"x":390.40675862630206,"y":438.0306091308594},"p2":{"x":386.89243570963544,"y":438.3235778808594},"p3":{"x":383.37811279296875,"y":438.6165466308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[118,119]},{"curve":{"p0":{"x":383.37811279296875,"y":438.6165466308594},"p1":{"x":381.32733154296875,"y":438.6165466308594},"p2":{"x":379.27655029296875,"y":438.6165466308594},"p3":{"x":377.22576904296875,"y":438.6165466308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[119,120]},{"curve":{"p0":{"x":377.22576904296875,"y":438.6165466308594},"p1":{"x":374.29738362630206,"y":438.6165466308594},"p2":{"x":371.36899820963544,"y":438.6165466308594},"p3":{"x":368.44061279296875,"y":438.6165466308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[120,121]},{"curve":{"p0":{"x":368.44061279296875,"y":438.6165466308594},"p1":{"x":365.80389404296875,"y":438.0306091308594},"p2":{"x":363.16717529296875,"y":437.4446716308594},"p3":{"x":360.53045654296875,"y":436.8587341308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[121,122]},{"curve":{"p0":{"x":360.53045654296875,"y":436.8587341308594},"p1":{"x":357.89503987630206,"y":435.9798278808594},"p2":{"x":355.25962320963544,"y":435.1009216308594},"p3":{"x":352.62420654296875,"y":434.2220153808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[122,123]},{"curve":{"p0":{"x":352.62420654296875,"y":434.2220153808594},"p1":{"x":349.69451904296875,"y":433.0501403808594},"p2":{"x":346.76483154296875,"y":431.8782653808594},"p3":{"x":343.83514404296875,"y":430.7063903808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[123,124]},{"curve":{"p0":{"x":343.83514404296875,"y":430.7063903808594},"p1":{"x":339.73488362630206,"y":428.9485778808594},"p2":{"x":335.63462320963544,"y":427.1907653808594},"p3":{"x":331.53436279296875,"y":425.4329528808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[124,125]},{"curve":{"p0":{"x":331.53436279296875,"y":425.4329528808594},"p1":{"x":329.19061279296875,"y":424.2623799641927},"p2":{"x":326.84686279296875,"y":423.09180704752606},"p3":{"x":324.50311279296875,"y":421.9212341308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[125,126]},{"curve":{"p0":{"x":324.50311279296875,"y":421.9212341308594},"p1":{"x":322.16066487630206,"y":420.7493591308594},"p2":{"x":319.81821695963544,"y":419.5774841308594},"p3":{"x":317.47576904296875,"y":418.4056091308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[126,127]},{"curve":{"p0":{"x":317.47576904296875,"y":418.4056091308594},"p1":{"x":314.54608154296875,"y":416.0618591308594},"p2":{"x":311.61639404296875,"y":413.7181091308594},"p3":{"x":308.68670654296875,"y":411.3743591308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[127,128]},{"curve":{"p0":{"x":308.68670654296875,"y":411.3743591308594},"p1":{"x":305.75832112630206,"y":408.7389424641927},"p2":{"x":302.82993570963544,"y":406.10352579752606},"p3":{"x":299.90155029296875,"y":403.4681091308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[128,129]},{"curve":{"p0":{"x":299.90155029296875,"y":403.4681091308594},"p1":{"x":297.55780029296875,"y":401.1243591308594},"p2":{"x":295.21405029296875,"y":398.7806091308594},"p3":{"x":292.87030029296875,"y":396.4368591308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[129,130]},{"curve":{"p0":{"x":292.87030029296875,"y":396.4368591308594},"p1":{"x":289.64894612630206,"y":392.9225362141927},"p2":{"x":286.42759195963544,"y":389.40821329752606},"p3":{"x":283.20623779296875,"y":385.8938903808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[130,131]},{"curve":{"p0":{"x":283.20623779296875,"y":385.8938903808594},"p1":{"x":282.03436279296875,"y":383.8431091308594},"p2":{"x":280.86248779296875,"y":381.7923278808594},"p3":{"x":279.69061279296875,"y":379.7415466308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[131,132]},{"curve":{"p0":{"x":279.69061279296875,"y":379.7415466308594},"p1":{"x":278.51873779296875,"y":377.1061299641927},"p2":{"x":277.34686279296875,"y":374.47071329752606},"p3":{"x":276.17498779296875,"y":371.8352966308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[132,133]},{"curve":{"p0":{"x":276.17498779296875,"y":367.4407653808594},"p1":{"x":276.17498779296875,"y":367.0084737141927},"p2":{"x":276.17498779296875,"y":366.576182047526},"p3":{"x":276.17498779296875,"y":366.1438903808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[134,135]},{"curve":{"p0":{"x":352.7765502929687,"y":366.1438903808594},"p1":{"x":327.24269612630206,"y":366.1438903808594},"p2":{"x":301.70884195963544,"y":366.1438903808594},"p3":{"x":276.17498779296875,"y":366.1438903808594}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[85,135]},{"curve":{"p0":{"x":276.17498779296875,"y":366.1438903808594},"p1":{"x":248.60858154296875,"y":366.1438903808594},"p2":{"x":221.04217529296875,"y":366.1438903808594},"p3":{"x":193.47576904296875,"y":366.1438903808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[135,3]},{"curve":{"p0":{"x":276.17498779296875,"y":367.4407653808594},"p1":{"x":276.17498779296875,"y":367.4865417480469},"p2":{"x":276.17498779296875,"y":367.4636535644531},"p3":{"x":276.17498779296875,"y":367.4407653808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[134,134]},{"curve":{"p0":{"x":276.17498779296875,"y":366.1438903808594},"p1":{"x":276.17498779296875,"y":365.404307047526},"p2":{"x":276.17498779296875,"y":364.6647237141927},"p3":{"x":276.17498779296875,"y":363.9251403808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[135,136]},{"curve":{"p0":{"x":276.17498779296875,"y":363.9251403808594},"p1":{"x":276.46795654296875,"y":362.1686299641927},"p2":{"x":276.76092529296875,"y":360.41211954752606},"p3":{"x":277.05389404296875,"y":358.6556091308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[136,137]},{"curve":{"p0":{"x":277.05389404296875,"y":358.6556091308594},"p1":{"x":277.93280029296875,"y":356.8977966308594},"p2":{"x":278.81170654296875,"y":355.1399841308594},"p3":{"x":279.69061279296875,"y":353.3821716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[137,138]},{"curve":{"p0":{"x":279.69061279296875,"y":353.3821716308594},"p1":{"x":280.56951904296875,"y":352.7962341308594},"p2":{"x":281.44842529296875,"y":352.2102966308594},"p3":{"x":282.32733154296875,"y":351.6243591308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[138,139]},{"curve":{"p0":{"x":282.32733154296875,"y":351.6243591308594},"p1":{"x":283.49920654296875,"y":350.4524841308594},"p2":{"x":284.67108154296875,"y":349.2806091308594},"p3":{"x":285.84295654296875,"y":348.1087341308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[139,140]},{"curve":{"p0":{"x":285.84295654296875,"y":348.1087341308594},"p1":{"x":286.13592529296875,"y":346.74415079752606},"p2":{"x":286.42889404296875,"y":345.3795674641927},"p3":{"x":286.72186279296875,"y":344.0149841308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[140,141]},{"curve":{"p0":{"x":286.72186279296875,"y":344.0149841308594},"p1":{"x":286.13592529296875,"y":343.1360778808594},"p2":{"x":285.54998779296875,"y":342.2571716308594},"p3":{"x":284.96405029296875,"y":341.3782653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[141,142]},{"curve":{"p0":{"x":284.96405029296875,"y":341.3782653808594},"p1":{"x":283.79217529296875,"y":340.4993591308594},"p2":{"x":282.62030029296875,"y":339.6204528808594},"p3":{"x":281.44842529296875,"y":338.7415466308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[142,143]},{"curve":{"p0":{"x":281.44842529296875,"y":338.7415466308594},"p1":{"x":279.69061279296875,"y":338.1556091308594},"p2":{"x":277.93280029296875,"y":337.5696716308594},"p3":{"x":276.17498779296875,"y":336.9837341308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[143,144]},{"curve":{"p0":{"x":276.17498779296875,"y":336.9837341308594},"p1":{"x":273.24660237630206,"y":336.3977966308594},"p2":{"x":270.31821695963544,"y":335.8118591308594},"p3":{"x":267.38983154296875,"y":335.2259216308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[144,145]},{"curve":{"p0":{"x":267.38983154296875,"y":335.2259216308594},"p1":{"x":266.21795654296875,"y":334.9329528808594},"p2":{"x":265.04608154296875,"y":334.6399841308594},"p3":{"x":263.87420654296875,"y":334.3470153808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[145,146]},{"curve":{"p0":{"x":263.87420654296875,"y":334.3470153808594},"p1":{"x":262.70233154296875,"y":333.7610778808594},"p2":{"x":261.53045654296875,"y":333.1751403808594},"p3":{"x":260.35858154296875,"y":332.5892028808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[146,147]},{"curve":{"p0":{"x":260.35858154296875,"y":332.5892028808594},"p1":{"x":259.18800862630206,"y":332.2962341308594},"p2":{"x":258.01743570963544,"y":332.0032653808594},"p3":{"x":256.84686279296875,"y":331.7102966308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[147,148]},{"curve":{"p0":{"x":256.84686279296875,"y":331.7102966308594},"p1":{"x":254.21014404296875,"y":330.8326924641927},"p2":{"x":251.57342529296875,"y":329.95508829752606},"p3":{"x":248.93670654296875,"y":329.0774841308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[148,149]},{"curve":{"p0":{"x":248.93670654296875,"y":329.0774841308594},"p1":{"x":247.17889404296875,"y":328.7845153808594},"p2":{"x":245.42108154296875,"y":328.4915466308594},"p3":{"x":243.66326904296875,"y":328.1985778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[149,150]},{"curve":{"p0":{"x":243.66326904296875,"y":328.1985778808594},"p1":{"x":240.7348836263021,"y":327.6126403808594},"p2":{"x":237.8064982096354,"y":327.0267028808594},"p3":{"x":234.87811279296875,"y":326.4407653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[150,151]},{"curve":{"p0":{"x":226.96795654296875,"y":326.4407653808594},"p1":{"x":225.2114461263021,"y":326.4407653808594},"p2":{"x":223.4549357096354,"y":326.4407653808594},"p3":{"x":221.69842529296875,"y":326.4407653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[153,154]},{"curve":{"p0":{"x":234.87811279296875,"y":326.4407653808594},"p1":{"x":233.72454833984375,"y":326.4407653808594},"p2":{"x":232.57098388671875,"y":326.4407653808594},"p3":{"x":231.36248779296875,"y":326.4407653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[151,152]},{"curve":{"p0":{"x":231.36248779296875,"y":326.4407653808594},"p1":{"x":231.39910888671875,"y":326.4407653808594},"p2":{"x":231.38079833984375,"y":326.4407653808594},"p3":{"x":231.36248779296875,"y":326.4407653808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[152,152]},{"curve":{"p0":{"x":231.36248779296875,"y":326.4407653808594},"p1":{"x":229.9205322265625,"y":326.4407653808594},"p2":{"x":228.47857666015625,"y":326.4407653808594},"p3":{"x":226.96795654296875,"y":326.4407653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[152,153]},{"curve":{"p0":{"x":226.96795654296875,"y":326.4407653808594},"p1":{"x":227.01373291015625,"y":326.4407653808594},"p2":{"x":226.9908447265625,"y":326.4407653808594},"p3":{"x":226.96795654296875,"y":326.4407653808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[153,153]},{"curve":{"p0":{"x":221.69842529296875,"y":326.4407653808594},"p1":{"x":219.06170654296875,"y":326.7337341308594},"p2":{"x":216.42498779296875,"y":327.0267028808594},"p3":{"x":213.78826904296875,"y":327.3196716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[154,155]},{"curve":{"p0":{"x":194.06561279296875,"y":327.3196716308594},"p1":{"x":192.89373779296875,"y":327.3196716308594},"p2":{"x":191.72186279296875,"y":327.3196716308594},"p3":{"x":190.54998779296875,"y":327.3196716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[160,161]},{"curve":{"p0":{"x":213.78826904296875,"y":327.3196716308594},"p1":{"x":212.0592041015625,"y":327.3196716308594},"p2":{"x":210.33013916015622,"y":327.3196716308594},"p3":{"x":208.51873779296875,"y":327.3196716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[155,156]},{"curve":{"p0":{"x":208.51873779296875,"y":327.3196716308594},"p1":{"x":208.57362874348956,"y":327.3196716308594},"p2":{"x":208.54618326822916,"y":327.3196716308594},"p3":{"x":208.51873779296875,"y":327.3196716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[156,156]},{"curve":{"p0":{"x":208.51873779296875,"y":327.3196716308594},"p1":{"x":207.36517333984375,"y":327.3196716308594},"p2":{"x":206.21160888671875,"y":327.3196716308594},"p3":{"x":205.00311279296875,"y":327.3196716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[156,157]},{"curve":{"p0":{"x":205.00311279296875,"y":327.3196716308594},"p1":{"x":205.03973388671875,"y":327.3196716308594},"p2":{"x":205.02142333984375,"y":327.3196716308594},"p3":{"x":205.00311279296875,"y":327.3196716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[157,157]},{"curve":{"p0":{"x":205.00311279296875,"y":327.3196716308594},"p1":{"x":203.6329345703125,"y":327.3196716308594},"p2":{"x":202.26275634765628,"y":327.3196716308594},"p3":{"x":200.82733154296875,"y":327.3196716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[157,158]},{"curve":{"p0":{"x":200.82733154296875,"y":327.3196716308594},"p1":{"x":200.87082926432294,"y":327.3196716308594},"p2":{"x":200.84908040364584,"y":327.3196716308594},"p3":{"x":200.82733154296875,"y":327.3196716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[158,158]},{"curve":{"p0":{"x":200.82733154296875,"y":327.3196716308594},"p1":{"x":199.70343017578125,"y":327.3196716308594},"p2":{"x":198.57952880859375,"y":327.3196716308594},"p3":{"x":197.34686279296875,"y":327.3196716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[158,159]},{"curve":{"p0":{"x":197.34686279296875,"y":327.3196716308594},"p1":{"x":197.41937255859375,"y":327.3196716308594},"p2":{"x":197.38311767578125,"y":327.3196716308594},"p3":{"x":197.34686279296875,"y":327.3196716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[159,159]},{"curve":{"p0":{"x":193.47576904296875,"y":366.1438903808594},"p1":{"x":193.47576904296875,"y":353.2024841308594},"p2":{"x":193.47576904296875,"y":340.2610778808594},"p3":{"x":194.06561279296875,"y":327.3196716308594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[3,160]},{"curve":{"p0":{"x":194.06561279296875,"y":327.3196716308594},"p1":{"x":193.47576904296875,"y":318.7243830306424},"p2":{"x":193.47576904296875,"y":310.1290944304254},"p3":{"x":193.47576904296875,"y":301.53380583020845}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[160,12]},{"curve":{"p0":{"x":194.06561279296875,"y":327.3196716308594},"p1":{"x":194.13397216796875,"y":327.3196716308594},"p2":{"x":194.09979248046875,"y":327.3196716308594},"p3":{"x":194.06561279296875,"y":327.3196716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[160,160]},{"curve":{"p0":{"x":190.54998779296875,"y":327.3196716308594},"p1":{"x":188.7934773763021,"y":327.6126403808594},"p2":{"x":187.0369669596354,"y":327.9056091308594},"p3":{"x":185.28045654296875,"y":328.1985778808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[161,162]},{"curve":{"p0":{"x":185.28045654296875,"y":328.1985778808594},"p1":{"x":182.35076904296875,"y":328.4915466308594},"p2":{"x":179.42108154296875,"y":328.7845153808594},"p3":{"x":176.49139404296875,"y":329.0774841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[162,163]},{"curve":{"p0":{"x":158.91717529296875,"y":329.0774841308594},"p1":{"x":157.7466023763021,"y":329.0774841308594},"p2":{"x":156.5760294596354,"y":329.0774841308594},"p3":{"x":155.40545654296875,"y":329.0774841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[166,167]},{"curve":{"p0":{"x":176.49139404296875,"y":329.0774841308594},"p1":{"x":174.7623291015625,"y":329.0774841308594},"p2":{"x":173.03326416015622,"y":329.0774841308594},"p3":{"x":171.22186279296875,"y":329.0774841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[163,164]},{"curve":{"p0":{"x":171.22186279296875,"y":329.0774841308594},"p1":{"x":171.27675374348956,"y":329.0774841308594},"p2":{"x":171.24930826822916,"y":329.0774841308594},"p3":{"x":171.22186279296875,"y":329.0774841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[164,164]},{"curve":{"p0":{"x":171.22186279296875,"y":329.0774841308594},"p1":{"x":169.49151611328125,"y":329.0774841308594},"p2":{"x":167.76116943359375,"y":329.0774841308594},"p3":{"x":165.94842529296875,"y":329.0774841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[164,165]},{"curve":{"p0":{"x":165.94842529296875,"y":329.0774841308594},"p1":{"x":166.00335693359375,"y":329.0774841308594},"p2":{"x":165.97589111328125,"y":329.0774841308594},"p3":{"x":165.94842529296875,"y":329.0774841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[165,165]},{"curve":{"p0":{"x":165.94842529296875,"y":329.0774841308594},"p1":{"x":163.64129638671875,"y":329.0774841308594},"p2":{"x":161.33416748046875,"y":329.0774841308594},"p3":{"x":158.91717529296875,"y":329.0774841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[165,166]},{"curve":{"p0":{"x":158.91717529296875,"y":329.0774841308594},"p1":{"x":158.99041748046875,"y":329.0774841308594},"p2":{"x":158.95379638671875,"y":329.0774841308594},"p3":{"x":158.91717529296875,"y":329.0774841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[166,166]},{"curve":{"p0":{"x":155.40545654296875,"y":329.0774841308594},"p1":{"x":154.23358154296875,"y":328.7845153808594},"p2":{"x":153.06170654296875,"y":328.4915466308594},"p3":{"x":151.88983154296875,"y":328.1985778808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[167,168]},{"curve":{"p0":{"x":151.88983154296875,"y":328.1985778808594},"p1":{"x":150.7400919596354,"y":327.9056091308594},"p2":{"x":149.5903523763021,"y":327.6126403808594},"p3":{"x":148.44061279296875,"y":327.3196716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[168,169]},{"curve":{"p0":{"x":148.44061279296875,"y":327.3196716308594},"p1":{"x":146.9770711263021,"y":326.7337341308594},"p2":{"x":145.5135294596354,"y":326.1477966308594},"p3":{"x":144.04998779296875,"y":325.5618591308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[169,170]},{"curve":{"p0":{"x":144.04998779296875,"y":325.5618591308594},"p1":{"x":142.9380086263021,"y":324.9954528808594},"p2":{"x":141.8260294596354,"y":324.4290466308594},"p3":{"x":140.71405029296875,"y":323.8626403808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[170,171]},{"curve":{"p0":{"x":140.71405029296875,"y":323.8626403808594},"p1":{"x":139.83514404296875,"y":322.9850362141927},"p2":{"x":138.95623779296875,"y":322.10743204752606},"p3":{"x":138.07733154296875,"y":321.2298278808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[171,172]},{"curve":{"p0":{"x":138.07733154296875,"y":321.2298278808594},"p1":{"x":148.6932169596354,"y":316.2884216308594},"p2":{"x":159.3091023763021,"y":311.3470153808594},"p3":{"x":169.92498779296875,"y":306.4056091308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[172,9]}],"fills":[{"boundary":[[7,"Backward"],[117,"Backward"],[116,"Backward"],[115,"Backward"],[114,"Backward"],[113,"Forward"],[80,"Backward"],[78,"Backward"],[77,"Backward"],[76,"Backward"],[75,"Backward"],[74,"Backward"],[73,"Backward"],[72,"Backward"],[71,"Backward"],[70,"Backward"],[69,"Backward"],[68,"Backward"],[65,"Backward"],[66,"Backward"],[64,"Backward"],[63,"Backward"],[62,"Backward"],[61,"Backward"],[60,"Backward"],[59,"Backward"],[58,"Backward"],[57,"Backward"],[56,"Backward"],[55,"Backward"],[52,"Backward"],[53,"Backward"],[51,"Backward"],[50,"Backward"],[49,"Backward"],[46,"Backward"],[47,"Backward"],[45,"Backward"],[44,"Backward"],[43,"Backward"],[38,"Backward"],[41,"Backward"],[39,"Backward"],[37,"Backward"],[34,"Backward"],[35,"Backward"],[32,"Backward"],[1,"Forward"],[6,"Forward"],[118,"Forward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":false,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null},{"boundary":[[7,"Forward"],[119,"Forward"],[112,"Forward"],[114,"Forward"],[115,"Forward"],[116,"Forward"],[117,"Forward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":true,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null},{"boundary":[[80,"Forward"],[97,"Forward"],[2,"Backward"],[96,"Backward"],[95,"Backward"],[90,"Backward"],[91,"Backward"],[89,"Backward"],[88,"Backward"],[87,"Backward"],[86,"Backward"],[85,"Backward"],[84,"Backward"],[83,"Backward"],[82,"Backward"],[81,"Backward"],[9,"Backward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":false,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null},{"boundary":[[33,"Forward"],[79,"Forward"],[9,"Forward"],[81,"Forward"],[82,"Forward"],[83,"Forward"],[84,"Forward"],[85,"Forward"],[86,"Forward"],[87,"Forward"],[88,"Forward"],[89,"Forward"],[91,"Forward"],[90,"Forward"],[95,"Forward"],[96,"Forward"],[2,"Forward"],[156,"Forward"],[159,"Forward"],[160,"Forward"],[161,"Forward"],[162,"Forward"],[163,"Forward"],[164,"Forward"],[165,"Forward"],[166,"Forward"],[167,"Forward"],[168,"Forward"],[169,"Forward"],[170,"Forward"],[171,"Forward"],[172,"Forward"],[173,"Forward"],[174,"Forward"],[176,"Forward"],[178,"Forward"],[175,"Forward"],[180,"Forward"],[182,"Forward"],[184,"Forward"],[186,"Forward"],[188,"Forward"],[12,"Forward"],[191,"Forward"],[14,"Forward"],[15,"Forward"],[16,"Forward"],[17,"Forward"],[18,"Forward"],[19,"Forward"],[20,"Forward"],[21,"Forward"],[22,"Forward"],[23,"Forward"],[24,"Forward"],[25,"Forward"],[26,"Forward"],[27,"Forward"],[28,"Forward"],[29,"Forward"],[30,"Forward"],[31,"Forward"],[8,"Forward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":true,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null},{"boundary":[[33,"Backward"],[35,"Forward"],[34,"Forward"],[37,"Forward"],[39,"Forward"],[41,"Forward"],[38,"Forward"],[43,"Forward"],[44,"Forward"],[45,"Forward"],[47,"Forward"],[46,"Forward"],[49,"Forward"],[50,"Forward"],[51,"Forward"],[53,"Forward"],[52,"Forward"],[55,"Forward"],[56,"Forward"],[57,"Forward"],[58,"Forward"],[59,"Forward"],[60,"Forward"],[61,"Forward"],[62,"Forward"],[63,"Forward"],[64,"Forward"],[66,"Forward"],[65,"Forward"],[68,"Forward"],[69,"Forward"],[70,"Forward"],[71,"Forward"],[72,"Forward"],[73,"Forward"],[74,"Forward"],[75,"Forward"],[76,"Forward"],[77,"Forward"],[78,"Forward"],[79,"Backward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":true,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null},{"boundary":[[186,"Backward"],[184,"Backward"],[182,"Backward"],[180,"Backward"],[175,"Backward"],[178,"Backward"],[176,"Backward"],[174,"Backward"],[173,"Backward"],[172,"Backward"],[171,"Backward"],[170,"Backward"],[169,"Backward"],[168,"Backward"],[167,"Backward"],[166,"Backward"],[165,"Backward"],[164,"Backward"],[163,"Backward"],[162,"Backward"],[161,"Backward"],[160,"Backward"],[159,"Backward"],[157,"Forward"],[190,"Forward"],[12,"Backward"],[188,"Backward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":false,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null},{"boundary":[[30,"Backward"],[29,"Backward"],[28,"Backward"],[27,"Backward"],[26,"Backward"],[25,"Backward"],[24,"Backward"],[23,"Backward"],[22,"Backward"],[21,"Backward"],[20,"Backward"],[19,"Backward"],[18,"Backward"],[17,"Backward"],[16,"Backward"],[15,"Backward"],[14,"Backward"],[13,"Forward"],[0,"Forward"],[4,"Forward"],[32,"Forward"],[8,"Backward"],[31,"Backward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":false,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null}],"free_edges":[36,40,42,48,54,67,92,93,94,106,108,110,128,130,135,158,177,179,183,185,187,189,192,197,199,201,3,5,10,11,98,99,100,101,102,103,104,105,107,109,111,120,121,122,123,124,125,126,127,129,131,132,133,134,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,181,193,194,195,196,198,200,202,203,204,205,206,207,191,79,156,119,112,33],"free_fills":[4,3,1],"free_vertices":[7,8],"vertices":[{"deleted":false,"position":{"x":193.47576904296875,"y":236.10092163085938}},{"deleted":false,"position":{"x":398.01873779296875,"y":236.10092163085938}},{"deleted":false,"position":{"x":398.01873779296875,"y":366.1438903808594}},{"deleted":false,"position":{"x":193.47576904296875,"y":366.1438903808594}},{"deleted":false,"position":{"x":398.01873779296875,"y":312.4524841308594}},{"deleted":false,"position":{"x":520.5656127929688,"y":312.4524841308594}},{"deleted":false,"position":{"x":520.5656127929688,"y":402.0149841308594}},{"deleted":true,"position":{"x":398.01873779296875,"y":402.0149841308594}},{"deleted":true,"position":{"x":398.01873779296875,"y":339.1288146972656}},{"deleted":false,"position":{"x":169.92498779296875,"y":306.4056091308594}},{"deleted":false,"position":{"x":178.71405029296875,"y":303.7688903808594}},{"deleted":false,"position":{"x":190.13592529296875,"y":302.0110778808594}},{"deleted":false,"position":{"x":193.47576904296878,"y":301.5338058302084}},{"deleted":false,"position":{"x":202.43670654296875,"y":300.2532653808594}},{"deleted":false,"position":{"x":214.73748779296875,"y":299.3782653808594}},{"deleted":false,"position":{"x":227.03826904296875,"y":298.4993591308594}},{"deleted":false,"position":{"x":238.46405029296875,"y":298.4993591308594}},{"deleted":false,"position":{"x":249.88592529296875,"y":298.4993591308594}},{"deleted":false,"position":{"x":266.58123779296875,"y":299.3782653808594}},{"deleted":false,"position":{"x":278.88201904296875,"y":299.3782653808594}},{"deleted":false,"position":{"x":290.30389404296875,"y":300.2532653808594}},{"deleted":false,"position":{"x":309.63592529296875,"y":303.7688903808594}},{"deleted":false,"position":{"x":322.81561279296875,"y":308.1634216308594}},{"deleted":false,"position":{"x":333.36248779296875,"y":312.5579528808594}},{"deleted":false,"position":{"x":342.14764404296875,"y":316.0696716308594}},{"deleted":false,"position":{"x":350.93280029296875,"y":319.5852966308594}},{"deleted":false,"position":{"x":358.84295654296875,"y":321.3431091308594}},{"deleted":false,"position":{"x":367.62811279296875,"y":324.8587341308594}},{"deleted":false,"position":{"x":376.41717529296875,"y":328.3743591308594}},{"deleted":false,"position":{"x":384.32342529296875,"y":331.0071716308594}},{"deleted":false,"position":{"x":392.23358154296875,"y":331.8860778808594}},{"deleted":false,"position":{"x":398.01873779296875,"y":331.8860778808594}},{"deleted":false,"position":{"x":400.13983154296875,"y":331.8860778808594}},{"deleted":false,"position":{"x":407.17108154296875,"y":331.8860778808594}},{"deleted":false,"position":{"x":415.95623779296875,"y":331.8860778808594}},{"deleted":false,"position":{"x":422.10858154296875,"y":331.8860778808594}},{"deleted":false,"position":{"x":430.01483154296875,"y":331.8860778808594}},{"deleted":false,"position":{"x":437.92498779296875,"y":331.8860778808594}},{"deleted":false,"position":{"x":444.95233154296875,"y":331.0071716308594}},{"deleted":false,"position":{"x":449.34686279296875,"y":331.0071716308594}},{"deleted":false,"position":{"x":453.74139404296875,"y":330.1321716308594}},{"deleted":false,"position":{"x":459.88983154296875,"y":330.1321716308594}},{"deleted":false,"position":{"x":463.40545654296875,"y":330.1321716308594}},{"deleted":false,"position":{"x":467.79998779296875,"y":331.0071716308594}},{"deleted":false,"position":{"x":471.31561279296875,"y":332.7649841308594}},{"deleted":false,"position":{"x":477.46405029296875,"y":338.9173278808594}},{"deleted":false,"position":{"x":479.22186279296875,"y":342.4329528808594}},{"deleted":false,"position":{"x":480.97967529296875,"y":345.9485778808594}},{"deleted":false,"position":{"x":481.85858154296875,"y":351.2181091308594}},{"deleted":false,"position":{"x":482.73748779296875,"y":355.6126403808594}},{"deleted":false,"position":{"x":483.61639404296875,"y":359.1282653808594}},{"deleted":false,"position":{"x":483.61639404296875,"y":364.3977966308594}},{"deleted":false,"position":{"x":482.73748779296875,"y":367.9134216308594}},{"deleted":false,"position":{"x":480.10076904296875,"y":374.0657653808594}},{"deleted":false,"position":{"x":478.34295654296875,"y":376.7024841308594}},{"deleted":false,"position":{"x":476.58514404296875,"y":379.3352966308594}},{"deleted":false,"position":{"x":474.82733154296875,"y":381.9720153808594}},{"deleted":false,"position":{"x":472.19451904296875,"y":383.7298278808594}},{"deleted":false,"position":{"x":468.67889404296875,"y":385.4876403808594}},{"deleted":false,"position":{"x":465.16326904296875,"y":387.2454528808594}},{"deleted":false,"position":{"x":454.62030029296875,"y":387.2454528808594}},{"deleted":false,"position":{"x":449.34686279296875,"y":385.4876403808594}},{"deleted":false,"position":{"x":444.07342529296875,"y":382.8509216308594}},{"deleted":false,"position":{"x":438.80389404296875,"y":381.0931091308594}},{"deleted":false,"position":{"x":429.13592529296875,"y":380.2142028808594}},{"deleted":false,"position":{"x":421.22967529296875,"y":377.5774841308594}},{"deleted":false,"position":{"x":414.19842529296875,"y":374.0657653808594}},{"deleted":false,"position":{"x":407.17108154296875,"y":367.9134216308594}},{"deleted":false,"position":{"x":402.77655029296875,"y":363.5188903808594}},{"deleted":false,"position":{"x":400.13983154296875,"y":360.8860778808594}},{"deleted":false,"position":{"x":398.38201904296875,"y":358.2493591308594}},{"deleted":false,"position":{"x":395.74920654296875,"y":352.0970153808594}},{"deleted":false,"position":{"x":394.02264404296875,"y":346.9290466308594}},{"deleted":false,"position":{"x":392.85467529296875,"y":343.4134216308594}},{"deleted":false,"position":{"x":388.80389404296875,"y":341.9876403808594}},{"deleted":false,"position":{"x":383.53045654296875,"y":341.9876403808594}},{"deleted":false,"position":{"x":379.13983154296875,"y":341.1087341308594}},{"deleted":false,"position":{"x":373.86639404296875,"y":341.9876403808594}},{"deleted":false,"position":{"x":369.47186279296875,"y":344.6243591308594}},{"deleted":false,"position":{"x":365.95623779296875,"y":347.2610778808594}},{"deleted":false,"position":{"x":363.32342529296875,"y":350.7727966308594}},{"deleted":false,"position":{"x":360.68670654296875,"y":353.4095153808594}},{"deleted":false,"position":{"x":358.04998779296875,"y":356.0462341308594}},{"deleted":false,"position":{"x":355.41326904296875,"y":360.4407653808594}},{"deleted":false,"position":{"x":352.77655029296875,"y":364.8313903808594}},{"deleted":false,"position":{"x":352.77655029296875,"y":366.1438903808594}},{"deleted":false,"position":{"x":352.77655029296875,"y":371.8509216308594}},{"deleted":false,"position":{"x":355.41326904296875,"y":376.2454528808594}},{"deleted":false,"position":{"x":357.16326904296875,"y":378.8704528808594}},{"deleted":false,"position":{"x":359.79998779296875,"y":381.5032653808594}},{"deleted":false,"position":{"x":365.94842529296875,"y":382.3821716308594}},{"deleted":false,"position":{"x":370.34295654296875,"y":382.3821716308594}},{"deleted":false,"position":{"x":374.73748779296875,"y":382.3821716308594}},{"deleted":false,"position":{"x":379.12811279296875,"y":382.3821716308594}},{"deleted":false,"position":{"x":386.15936279296875,"y":382.3821716308594}},{"deleted":false,"position":{"x":391.42889404296875,"y":384.1399841308594}},{"deleted":false,"position":{"x":398.01873779296875,"y":387.06880357530383}},{"deleted":false,"position":{"x":399.33905029296875,"y":387.6556091308594}},{"deleted":false,"position":{"x":406.36639404296875,"y":391.1712341308594}},{"deleted":false,"position":{"x":415.15545654296875,"y":398.1985778808594}},{"deleted":false,"position":{"x":419.54998779296875,"y":400.8352966308594}},{"deleted":false,"position":{"x":421.02328640407984,"y":402.0149841308594}},{"deleted":false,"position":{"x":423.94061279296875,"y":404.3509216308594}},{"deleted":false,"position":{"x":427.45623779296875,"y":406.1087341308594}},{"deleted":false,"position":{"x":431.84686279296875,"y":406.1087341308594}},{"deleted":false,"position":{"x":435.35858154296875,"y":406.9837341308594}},{"deleted":false,"position":{"x":438.73358154296875,"y":408.7376403808594}},{"deleted":false,"position":{"x":441.37030029296875,"y":411.3743591308594}},{"deleted":false,"position":{"x":441.37030029296875,"y":416.6477966308594}},{"deleted":false,"position":{"x":441.37030029296875,"y":421.9212341308594}},{"deleted":false,"position":{"x":441.37030029296875,"y":425.4329528808594}},{"deleted":false,"position":{"x":438.73358154296875,"y":428.0696716308594}},{"deleted":false,"position":{"x":434.33905029296875,"y":429.8274841308594}},{"deleted":false,"position":{"x":429.06951904296875,"y":429.8274841308594}},{"deleted":false,"position":{"x":424.67498779296875,"y":429.8274841308594}},{"deleted":false,"position":{"x":420.28045654296875,"y":431.5852966308594}},{"deleted":false,"position":{"x":409.73748779296875,"y":434.2220153808594}},{"deleted":false,"position":{"x":401.82733154296875,"y":436.8587341308594}},{"deleted":false,"position":{"x":393.92108154296875,"y":437.7376403808594}},{"deleted":false,"position":{"x":383.37811279296875,"y":438.6165466308594}},{"deleted":false,"position":{"x":377.22576904296875,"y":438.6165466308594}},{"deleted":false,"position":{"x":368.44061279296875,"y":438.6165466308594}},{"deleted":false,"position":{"x":360.53045654296875,"y":436.8587341308594}},{"deleted":false,"position":{"x":352.62420654296875,"y":434.2220153808594}},{"deleted":false,"position":{"x":343.83514404296875,"y":430.7063903808594}},{"deleted":false,"position":{"x":331.53436279296875,"y":425.4329528808594}},{"deleted":false,"position":{"x":324.50311279296875,"y":421.9212341308594}},{"deleted":false,"position":{"x":317.47576904296875,"y":418.4056091308594}},{"deleted":false,"position":{"x":308.68670654296875,"y":411.3743591308594}},{"deleted":false,"position":{"x":299.90155029296875,"y":403.4681091308594}},{"deleted":false,"position":{"x":292.87030029296875,"y":396.4368591308594}},{"deleted":false,"position":{"x":283.20623779296875,"y":385.8938903808594}},{"deleted":false,"position":{"x":279.69061279296875,"y":379.7415466308594}},{"deleted":false,"position":{"x":276.17498779296875,"y":371.8352966308594}},{"deleted":false,"position":{"x":276.17498779296875,"y":367.4407653808594}},{"deleted":false,"position":{"x":276.17498779296875,"y":366.1438903808594}},{"deleted":false,"position":{"x":276.17498779296875,"y":363.9251403808594}},{"deleted":false,"position":{"x":277.05389404296875,"y":358.6556091308594}},{"deleted":false,"position":{"x":279.69061279296875,"y":353.3821716308594}},{"deleted":false,"position":{"x":282.32733154296875,"y":351.6243591308594}},{"deleted":false,"position":{"x":285.84295654296875,"y":348.1087341308594}},{"deleted":false,"position":{"x":286.72186279296875,"y":344.0149841308594}},{"deleted":false,"position":{"x":284.96405029296875,"y":341.3782653808594}},{"deleted":false,"position":{"x":281.44842529296875,"y":338.7415466308594}},{"deleted":false,"position":{"x":276.17498779296875,"y":336.9837341308594}},{"deleted":false,"position":{"x":267.38983154296875,"y":335.2259216308594}},{"deleted":false,"position":{"x":263.87420654296875,"y":334.3470153808594}},{"deleted":false,"position":{"x":260.35858154296875,"y":332.5892028808594}},{"deleted":false,"position":{"x":256.84686279296875,"y":331.7102966308594}},{"deleted":false,"position":{"x":248.93670654296875,"y":329.0774841308594}},{"deleted":false,"position":{"x":243.66326904296875,"y":328.1985778808594}},{"deleted":false,"position":{"x":234.87811279296875,"y":326.4407653808594}},{"deleted":false,"position":{"x":231.36248779296875,"y":326.4407653808594}},{"deleted":false,"position":{"x":226.96795654296875,"y":326.4407653808594}},{"deleted":false,"position":{"x":221.69842529296875,"y":326.4407653808594}},{"deleted":false,"position":{"x":213.78826904296875,"y":327.3196716308594}},{"deleted":false,"position":{"x":208.51873779296875,"y":327.3196716308594}},{"deleted":false,"position":{"x":205.00311279296875,"y":327.3196716308594}},{"deleted":false,"position":{"x":200.82733154296875,"y":327.3196716308594}},{"deleted":false,"position":{"x":197.34686279296875,"y":327.3196716308594}},{"deleted":false,"position":{"x":194.06561279296875,"y":327.3196716308594}},{"deleted":false,"position":{"x":190.54998779296875,"y":327.3196716308594}},{"deleted":false,"position":{"x":185.28045654296875,"y":328.1985778808594}},{"deleted":false,"position":{"x":176.49139404296875,"y":329.0774841308594}},{"deleted":false,"position":{"x":171.22186279296875,"y":329.0774841308594}},{"deleted":false,"position":{"x":165.94842529296875,"y":329.0774841308594}},{"deleted":false,"position":{"x":158.91717529296875,"y":329.0774841308594}},{"deleted":false,"position":{"x":155.40545654296875,"y":329.0774841308594}},{"deleted":false,"position":{"x":151.88983154296875,"y":328.1985778808594}},{"deleted":false,"position":{"x":148.44061279296875,"y":327.3196716308594}},{"deleted":false,"position":{"x":144.04998779296875,"y":325.5618591308594}},{"deleted":false,"position":{"x":140.71405029296875,"y":323.8626403808594}},{"deleted":false,"position":{"x":138.07733154296875,"y":321.2298278808594}}]},"segments":[[[254.51873779296875,195.74154663085938],[253.63983154296875,196.91211954752603],[252.76092529296875,198.08269246419272],[251.88201904296875,199.25326538085938]],[[251.88201904296875,199.25326538085938],[251.2973836263021,200.13217163085938],[250.7127482096354,201.01107788085938],[250.12811279296875,201.88998413085938]],[[250.12811279296875,201.88998413085938],[249.24920654296875,203.06185913085938],[248.37030029296875,204.23373413085938],[247.49139404296875,205.40560913085938]],[[247.49139404296875,205.40560913085938],[246.31951904296875,206.87045288085938],[245.14764404296875,208.33529663085938],[243.97576904296875,209.80014038085938]],[[243.97576904296875,209.80014038085938],[243.09686279296875,210.67904663085938],[242.21795654296875,211.55795288085938],[241.33905029296875,212.43685913085938]],[[241.33905029296875,212.43685913085938],[239.58123779296875,214.48633829752603],[237.82342529296875,216.53581746419272],[236.06561279296875,218.58529663085938]],[[236.06561279296875,218.58529663085938],[235.4809773763021,220.05014038085938],[234.8963419596354,221.51498413085938],[234.31170654296875,222.97982788085938]],[[234.31170654296875,222.97982788085938],[233.72576904296875,224.15170288085938],[233.13983154296875,225.32357788085938],[232.55389404296875,226.49545288085938]],[[232.55389404296875,226.49545288085938],[231.96795654296875,228.54493204752603],[231.38201904296875,230.59441121419272],[230.79608154296875,232.64389038085938]],[[230.79608154296875,232.64389038085938],[230.79608154296875,234.10873413085938],[230.79608154296875,235.57357788085938],[230.79608154296875,237.03842163085938]],[[230.79608154296875,237.03842163085938],[230.79608154296875,238.21029663085938],[230.79608154296875,239.38217163085938],[230.79608154296875,240.55404663085938]],[[230.79608154296875,240.55404663085938],[230.50311279296875,242.60352579752603],[230.21014404296875,244.65300496419272],[229.91717529296875,246.70248413085938]],[[229.91717529296875,246.70248413085938],[229.62420654296875,247.87435913085938],[229.33123779296875,249.04623413085938],[229.03826904296875,250.21810913085938]],[[229.03826904296875,250.21810913085938],[228.45233154296875,251.68295288085938],[227.86639404296875,253.14779663085938],[227.28045654296875,254.61264038085938]],[[227.28045654296875,254.61264038085938],[226.69451904296875,256.6634216308594],[226.10858154296875,258.7142028808594],[225.52264404296875,260.7649841308594]],[[225.52264404296875,260.7649841308594],[224.93670654296875,263.10743204752606],[224.35076904296875,265.4498799641927],[223.76483154296875,267.7923278808594]],[[223.76483154296875,267.7923278808594],[222.59295654296875,272.47852579752606],[221.42108154296875,277.1647237141927],[220.24920654296875,281.8509216308594]],[[220.24920654296875,281.8509216308594],[219.3716023763021,285.95118204752606],[218.4939982096354,290.0514424641927],[217.61639404296875,294.1517028808594]],[[217.61639404296875,294.1517028808594],[216.73748779296875,298.2532653808594],[215.85858154296875,302.3548278808594],[214.97967529296875,306.4563903808594]],[[214.97967529296875,306.4563903808594],[213.80780029296875,310.26368204752606],[212.63592529296875,314.0709737141927],[211.46405029296875,317.8782653808594]],[[211.46405029296875,317.8782653808594],[210.29217529296875,321.09961954752606],[209.12030029296875,324.3209737141927],[207.94842529296875,327.5423278808594]],[[207.94842529296875,327.5423278808594],[207.65545654296875,329.8860778808594],[207.36248779296875,332.2298278808594],[207.06951904296875,334.5735778808594]],[[207.06951904296875,334.5735778808594],[206.77655029296875,336.91602579752606],[206.48358154296875,339.2584737141927],[206.19061279296875,341.6009216308594]],[[206.19061279296875,341.6009216308594],[206.19061279296875,343.6517028808594],[206.19061279296875,345.7024841308594],[206.19061279296875,347.7532653808594]],[[206.19061279296875,347.7532653808594],[206.19061279296875,349.5110778808594],[206.19061279296875,351.2688903808594],[206.19061279296875,353.0267028808594]],[[206.19061279296875,353.0267028808594],[206.19061279296875,354.19727579752606],[206.19061279296875,355.3678487141927],[206.19061279296875,356.5384216308594]],[[206.19061279296875,356.5384216308594],[206.19061279296875,358.8821716308594],[206.19061279296875,361.2259216308594],[206.19061279296875,363.5696716308594]],[[206.19061279296875,363.5696716308594],[206.48358154296875,364.7415466308594],[206.77655029296875,365.9134216308594],[207.06951904296875,367.0852966308594]],[[207.06951904296875,367.0852966308594],[207.06951904296875,368.84180704752606],[207.06951904296875,370.5983174641927],[207.06951904296875,372.3548278808594]],[[207.06951904296875,372.3548278808594],[207.36248779296875,376.1634216308594],[207.65545654296875,379.9720153808594],[207.94842529296875,383.7806091308594]],[[207.94842529296875,383.7806091308594],[208.24139404296875,386.12305704752606],[208.53436279296875,388.4655049641927],[208.82733154296875,390.8079528808594]],[[208.82733154296875,390.8079528808594],[208.82733154296875,392.8587341308594],[208.82733154296875,394.9095153808594],[208.82733154296875,396.9602966308594]],[[208.82733154296875,396.9602966308594],[208.82733154296875,399.59571329752606],[208.82733154296875,402.2311299641927],[208.82733154296875,404.8665466308594]],[[208.82733154296875,404.8665466308594],[208.82733154296875,406.9173278808594],[208.82733154296875,408.9681091308594],[208.82733154296875,411.0188903808594]],[[208.82733154296875,411.0188903808594],[209.12030029296875,413.06836954752606],[209.41326904296875,415.1178487141927],[209.70623779296875,417.1673278808594]],[[209.70623779296875,417.1673278808594],[209.70623779296875,418.9251403808594],[209.70623779296875,420.6829528808594],[209.70623779296875,422.4407653808594]],[[209.70623779296875,422.4407653808594],[209.70623779296875,423.6126403808594],[209.70623779296875,424.7845153808594],[209.70623779296875,425.9563903808594]],[[209.70623779296875,425.9563903808594],[209.70623779296875,427.1022237141927],[209.70623779296875,428.24805704752606],[209.70623779296875,429.3938903808594]],[[209.70623779296875,429.3938903808594],[210.9966023763021,426.80665079752606],[212.2869669596354,424.2194112141927],[213.57733154296875,421.6321716308594]],[[213.57733154296875,421.6321716308594],[213.87030029296875,420.1686299641927],[214.16326904296875,418.70508829752606],[214.45623779296875,417.2415466308594]],[[214.45623779296875,417.2415466308594],[215.33514404296875,415.1907653808594],[216.21405029296875,413.1399841308594],[217.09295654296875,411.0892028808594]],[[217.09295654296875,411.0892028808594],[217.67889404296875,409.9173278808594],[218.26483154296875,408.7454528808594],[218.85076904296875,407.5735778808594]],[[218.85076904296875,407.5735778808594],[219.4354044596354,406.1087341308594],[220.0200398763021,404.6438903808594],[220.60467529296875,403.1790466308594]],[[220.60467529296875,403.1790466308594],[221.19061279296875,401.1295674641927],[221.77655029296875,399.08008829752606],[222.36248779296875,397.0306091308594]],[[222.36248779296875,397.0306091308594],[222.65545654296875,395.5657653808594],[222.94842529296875,394.1009216308594],[223.24139404296875,392.6360778808594]],[[223.24139404296875,392.6360778808594],[223.53436279296875,391.1712341308594],[223.82733154296875,389.7063903808594],[224.12030029296875,388.2415466308594]],[[224.12030029296875,388.2415466308594],[224.41326904296875,386.7780049641927],[224.70623779296875,385.31446329752606],[224.99920654296875,383.8509216308594]],[[224.99920654296875,383.8509216308594],[225.29217529296875,382.6803487141927],[225.58514404296875,381.50977579752606],[225.87811279296875,380.3392028808594]],[[225.87811279296875,380.3392028808594],[226.17108154296875,378.5813903808594],[226.46405029296875,376.8235778808594],[226.75701904296875,375.0657653808594]],[[226.75701904296875,375.0657653808594],[226.75701904296875,373.0149841308594],[226.75701904296875,370.9642028808594],[226.75701904296875,368.9134216308594]],[[226.75701904296875,368.9134216308594],[227.34295654296875,365.9850362141927],[227.92889404296875,363.05665079752606],[228.51483154296875,360.1282653808594]],[[228.51483154296875,360.1282653808594],[229.97967529296875,356.3209737141927],[231.44451904296875,352.51368204752606],[232.90936279296875,348.7063903808594]],[[232.90936279296875,348.7063903808594],[234.6658732096354,345.1907653808594],[236.4223836263021,341.6751403808594],[238.17889404296875,338.1595153808594]],[[238.17889404296875,338.1595153808594],[240.52264404296875,333.4733174641927],[242.86639404296875,328.78711954752606],[245.21014404296875,324.1009216308594]],[[245.21014404296875,324.1009216308594],[246.67498779296875,321.7584737141927],[248.13983154296875,319.41602579752606],[249.60467529296875,317.0735778808594]],[[249.60467529296875,317.0735778808594],[250.4822794596354,315.0227966308594],[251.3598836263021,312.9720153808594],[252.23748779296875,310.9212341308594]],[[252.23748779296875,310.9212341308594],[253.99530029296875,307.6998799641927],[255.75311279296875,304.47852579752606],[257.51092529296875,301.2571716308594]],[[257.51092529296875,301.2571716308594],[258.38983154296875,299.2063903808594],[259.26873779296875,297.1556091308594],[260.14764404296875,295.1048278808594]],[[260.14764404296875,295.1048278808594],[261.31951904296875,292.7610778808594],[262.49139404296875,290.4173278808594],[263.66326904296875,288.0735778808594]],[[263.66326904296875,288.0735778808594],[265.12681070963544,285.1451924641927],[266.59035237630206,282.21680704752606],[268.05389404296875,279.2884216308594]],[[268.05389404296875,279.2884216308594],[268.93280029296875,278.1165466308594],[269.81170654296875,276.9446716308594],[270.69061279296875,275.7727966308594]],[[270.69061279296875,275.7727966308594],[271.56951904296875,274.8938903808594],[272.44842529296875,274.0149841308594],[273.32733154296875,273.1360778808594]],[[273.32733154296875,273.1360778808594],[274.20623779296875,272.5514424641927],[275.08514404296875,271.96680704752606],[275.96405029296875,271.3821716308594]],[[275.96405029296875,271.3821716308594],[276.84295654296875,270.7962341308594],[277.72186279296875,270.2102966308594],[278.60076904296875,269.6243591308594]],[[278.60076904296875,269.6243591308594],[279.47967529296875,268.7454528808594],[280.35858154296875,267.8665466308594],[281.23748779296875,266.9876403808594]],[[281.23748779296875,266.9876403808594],[282.11509195963544,266.4017028808594],[282.99269612630206,265.8157653808594],[283.87030029296875,265.2298278808594]],[[283.87030029296875,265.2298278808594],[284.74920654296875,264.6438903808594],[285.62811279296875,264.0579528808594],[286.50701904296875,263.4720153808594]],[[286.50701904296875,263.4720153808594],[288.26483154296875,262.3001403808594],[290.02264404296875,261.1282653808594],[291.78045654296875,259.9563903808594]],[[291.78045654296875,259.9563903808594],[292.65936279296875,259.3704528808594],[293.53826904296875,258.7845153808594],[294.41717529296875,258.1985778808594]],[[294.41717529296875,258.1985778808594],[295.58774820963544,257.3209737141927],[296.75832112630206,256.44336954752606],[297.92889404296875,255.56576538085938]],[[297.92889404296875,255.56576538085938],[299.68670654296875,254.39389038085938],[301.44451904296875,253.22201538085938],[303.20233154296875,252.05014038085938]],[[303.20233154296875,252.05014038085938],[304.37420654296875,251.46420288085938],[305.54608154296875,250.87826538085938],[306.71795654296875,250.29232788085938]],[[306.71795654296875,250.29232788085938],[307.88592529296875,250.29232788085938],[309.05389404296875,250.29232788085938],[310.22186279296875,250.29232788085938]],[[310.22186279296875,250.29232788085938],[311.26222737630206,250.29232788085938],[312.30259195963544,250.29232788085938],[313.34295654296875,250.29232788085938]],[[313.34295654296875,250.29232788085938],[314.22056070963544,250.87826538085938],[315.09816487630206,251.46420288085938],[315.97576904296875,252.05014038085938]],[[315.97576904296875,252.05014038085938],[316.56170654296875,252.92774454752603],[317.14764404296875,253.80534871419272],[317.73358154296875,254.68295288085938]],[[317.73358154296875,254.68295288085938],[318.90545654296875,255.85482788085938],[320.07733154296875,257.0267028808594],[321.24920654296875,258.1985778808594]],[[321.24920654296875,258.1985778808594],[322.12811279296875,259.0774841308594],[323.00701904296875,259.9563903808594],[323.88592529296875,260.8352966308594]],[[323.88592529296875,260.8352966308594],[324.86248779296875,261.29493204752606],[325.83905029296875,261.7545674641927],[326.81561279296875,262.2142028808594]],[[326.81561279296875,262.2142028808594],[302.71665445963544,240.05665079752603],[278.61769612630206,217.89909871419272],[254.51873779296875,195.74154663085938]]]} \ No newline at end of file diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_dumps/dump4.json b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_dumps/dump4.json new file mode 100644 index 0000000..1f0cdc0 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/region_dumps/dump4.json @@ -0,0 +1 @@ +{"graph":{"edges":[{"curve":{"p0":{"x":193.47576904296875,"y":236.10092163085938},"p1":{"x":261.65675862630206,"y":236.10092163085938},"p2":{"x":329.8377482096354,"y":236.10092163085938},"p3":{"x":398.01873779296875,"y":236.10092163085938}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[0,1]},{"curve":{"p0":{"x":398.01873779296875,"y":312.4524841308594},"p1":{"x":438.86769612630206,"y":312.4524841308594},"p2":{"x":479.7166544596354,"y":312.4524841308594},"p3":{"x":520.5656127929688,"y":312.4524841308594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[4,5]},{"curve":{"p0":{"x":352.77655029296875,"y":364.8313903808594},"p1":{"x":352.77655029296875,"y":365.2688903808594},"p2":{"x":352.77655029296875,"y":365.7063903808594},"p3":{"x":352.77655029296875,"y":366.1438903808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[84,85]},{"curve":{"p0":{"x":190.13592529296875,"y":302.0110778808594},"p1":{"x":191.24920654296875,"y":301.85198719730903},"p2":{"x":192.36248779296875,"y":301.6928965137587},"p3":{"x":193.47576904296878,"y":301.5338058302084}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[11,12]},{"curve":{"p0":{"x":398.01873779296875,"y":236.10092163085938},"p1":{"x":398.01873779296875,"y":261.5514424641927},"p2":{"x":398.01873779296875,"y":287.001963297526},"p3":{"x":398.01873779296875,"y":312.4524841308593}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[1,4]},{"curve":{"p0":{"x":391.42889404296875,"y":384.1399841308594},"p1":{"x":393.62550862630206,"y":385.1162572790075},"p2":{"x":395.8221232096354,"y":386.0925304271557},"p3":{"x":398.01873779296875,"y":387.06880357530383}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[95,96]},{"curve":{"p0":{"x":520.5656127929688,"y":312.4524841308594},"p1":{"x":520.5656127929688,"y":342.30665079752606},"p2":{"x":520.5656127929688,"y":372.1608174641927},"p3":{"x":520.5656127929688,"y":402.0149841308594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[5,6]},{"curve":{"p0":{"x":419.54998779296875,"y":400.8352966308594},"p1":{"x":420.0410873300058,"y":401.22852579752606},"p2":{"x":420.5321868670428,"y":401.62175496419275},"p3":{"x":421.02328640407984,"y":402.0149841308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[100,101]},{"curve":{"p0":{"x":392.23358154296875,"y":331.8860778808594},"p1":{"x":394.16196695963544,"y":331.8860778808594},"p2":{"x":396.09035237630206,"y":331.8860778808594},"p3":{"x":398.01873779296875,"y":331.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[30,31]},{"curve":{"p0":{"x":398.38201904296875,"y":358.2493591308594},"p1":{"x":397.50441487630206,"y":356.1985778808594},"p2":{"x":396.62681070963544,"y":354.1477966308594},"p3":{"x":395.74920654296875,"y":352.0970153808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[70,71]},{"curve":{"p0":{"x":178.71405029296875,"y":303.7688903808594},"p1":{"x":182.5213419596354,"y":303.1829528808594},"p2":{"x":186.3286336263021,"y":302.5970153808594},"p3":{"x":190.13592529296875,"y":302.0110778808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[10,11]},{"curve":{"p0":{"x":169.92498779296875,"y":306.4056091308594},"p1":{"x":172.85467529296875,"y":305.5267028808594},"p2":{"x":175.78436279296875,"y":304.6477966308594},"p3":{"x":178.71405029296875,"y":303.7688903808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[9,10]},{"curve":{"p0":{"x":197.34686279296875,"y":327.3196716308594},"p1":{"x":196.28729248046875,"y":327.3196716308594},"p2":{"x":195.22772216796875,"y":327.3196716308594},"p3":{"x":194.06561279296875,"y":327.3196716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[159,160]},{"curve":{"p0":{"x":193.47576904296875,"y":301.53380583020845},"p1":{"x":193.47576904296875,"y":279.7228444304254},"p2":{"x":193.47576904296875,"y":257.9118830306424},"p3":{"x":193.47576904296875,"y":236.10092163085938}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[12,0]},{"curve":{"p0":{"x":193.47576904296878,"y":301.5338058302084},"p1":{"x":196.4627482096354,"y":301.1069590137587},"p2":{"x":199.4497273763021,"y":300.68011219730903},"p3":{"x":202.43670654296875,"y":300.2532653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[12,13]},{"curve":{"p0":{"x":202.43670654296875,"y":300.2532653808594},"p1":{"x":206.5369669596354,"y":299.9615987141927},"p2":{"x":210.6372273763021,"y":299.66993204752606},"p3":{"x":214.73748779296875,"y":299.3782653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[13,14]},{"curve":{"p0":{"x":214.73748779296875,"y":299.3782653808594},"p1":{"x":218.8377482096354,"y":299.0852966308594},"p2":{"x":222.9380086263021,"y":298.7923278808594},"p3":{"x":227.03826904296875,"y":298.4993591308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[14,15]},{"curve":{"p0":{"x":227.03826904296875,"y":298.4993591308594},"p1":{"x":230.84686279296875,"y":298.4993591308594},"p2":{"x":234.65545654296875,"y":298.4993591308594},"p3":{"x":238.46405029296875,"y":298.4993591308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[15,16]},{"curve":{"p0":{"x":238.46405029296875,"y":298.4993591308594},"p1":{"x":242.2713419596354,"y":298.4993591308594},"p2":{"x":246.0786336263021,"y":298.4993591308594},"p3":{"x":249.88592529296875,"y":298.4993591308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[16,17]},{"curve":{"p0":{"x":249.88592529296875,"y":298.4993591308594},"p1":{"x":255.4510294596354,"y":298.7923278808594},"p2":{"x":261.01613362630206,"y":299.0852966308594},"p3":{"x":266.58123779296875,"y":299.3782653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[17,18]},{"curve":{"p0":{"x":266.58123779296875,"y":299.3782653808594},"p1":{"x":270.68149820963544,"y":299.3782653808594},"p2":{"x":274.78175862630206,"y":299.3782653808594},"p3":{"x":278.88201904296875,"y":299.3782653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[18,19]},{"curve":{"p0":{"x":278.88201904296875,"y":299.3782653808594},"p1":{"x":282.68931070963544,"y":299.66993204752606},"p2":{"x":286.49660237630206,"y":299.9615987141927},"p3":{"x":290.30389404296875,"y":300.2532653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[19,20]},{"curve":{"p0":{"x":290.30389404296875,"y":300.2532653808594},"p1":{"x":296.74790445963544,"y":301.4251403808594},"p2":{"x":303.19191487630206,"y":302.5970153808594},"p3":{"x":309.63592529296875,"y":303.7688903808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[20,21]},{"curve":{"p0":{"x":309.63592529296875,"y":303.7688903808594},"p1":{"x":314.02915445963544,"y":305.2337341308594},"p2":{"x":318.42238362630206,"y":306.6985778808594},"p3":{"x":322.81561279296875,"y":308.1634216308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[21,22]},{"curve":{"p0":{"x":322.81561279296875,"y":308.1634216308594},"p1":{"x":326.33123779296875,"y":309.6282653808594},"p2":{"x":329.84686279296875,"y":311.0931091308594},"p3":{"x":333.36248779296875,"y":312.5579528808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[22,23]},{"curve":{"p0":{"x":333.36248779296875,"y":312.5579528808594},"p1":{"x":336.29087320963544,"y":313.72852579752606},"p2":{"x":339.21925862630206,"y":314.8990987141927},"p3":{"x":342.14764404296875,"y":316.0696716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[23,24]},{"curve":{"p0":{"x":342.14764404296875,"y":316.0696716308594},"p1":{"x":345.07602945963544,"y":317.2415466308594},"p2":{"x":348.00441487630206,"y":318.4134216308594},"p3":{"x":350.93280029296875,"y":319.5852966308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[24,25]},{"curve":{"p0":{"x":350.93280029296875,"y":319.5852966308594},"p1":{"x":353.56951904296875,"y":320.1712341308594},"p2":{"x":356.20623779296875,"y":320.7571716308594},"p3":{"x":358.84295654296875,"y":321.3431091308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[25,26]},{"curve":{"p0":{"x":358.84295654296875,"y":321.3431091308594},"p1":{"x":361.77134195963544,"y":322.5149841308594},"p2":{"x":364.69972737630206,"y":323.6868591308594},"p3":{"x":367.62811279296875,"y":324.8587341308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[26,27]},{"curve":{"p0":{"x":367.62811279296875,"y":324.8587341308594},"p1":{"x":370.55780029296875,"y":326.0306091308594},"p2":{"x":373.48748779296875,"y":327.2024841308594},"p3":{"x":376.41717529296875,"y":328.3743591308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[27,28]},{"curve":{"p0":{"x":376.41717529296875,"y":328.3743591308594},"p1":{"x":379.05259195963544,"y":329.25196329752606},"p2":{"x":381.68800862630206,"y":330.1295674641927},"p3":{"x":384.32342529296875,"y":331.0071716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[28,29]},{"curve":{"p0":{"x":384.32342529296875,"y":331.0071716308594},"p1":{"x":386.96014404296875,"y":331.3001403808594},"p2":{"x":389.59686279296875,"y":331.5931091308594},"p3":{"x":392.23358154296875,"y":331.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[29,30]},{"curve":{"p0":{"x":398.01873779296875,"y":312.4524841308593},"p1":{"x":398.01873779296875,"y":318.93034871419263},"p2":{"x":398.01873779296875,"y":325.408213297526},"p3":{"x":398.01873779296875,"y":331.8860778808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[4,31]},{"curve":{"p0":{"x":398.01873779296875,"y":331.8860778808594},"p1":{"x":398.01873779296875,"y":334.4092140197754},"p2":{"x":398.01873779296875,"y":336.9323501586914},"p3":{"x":398.01873779296875,"y":339.4554862976074}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[31,8]},{"curve":{"p0":{"x":400.13983154296875,"y":331.8860778808594},"p1":{"x":402.48358154296875,"y":331.8860778808594},"p2":{"x":404.82733154296875,"y":331.8860778808594},"p3":{"x":407.17108154296875,"y":331.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[32,33]},{"curve":{"p0":{"x":398.01873779296875,"y":331.8860778808594},"p1":{"x":398.692626953125,"y":331.8860778808594},"p2":{"x":399.36651611328125,"y":331.8860778808594},"p3":{"x":400.13983154296875,"y":331.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[31,32]},{"curve":{"p0":{"x":400.13983154296875,"y":331.8860778808594},"p1":{"x":400.07354736328125,"y":331.8860778808594},"p2":{"x":400.106689453125,"y":331.8860778808594},"p3":{"x":400.13983154296875,"y":331.8860778808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[32,32]},{"curve":{"p0":{"x":407.17108154296875,"y":331.8860778808594},"p1":{"x":410.09946695963544,"y":331.8860778808594},"p2":{"x":413.02785237630206,"y":331.8860778808594},"p3":{"x":415.95623779296875,"y":331.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[33,34]},{"curve":{"p0":{"x":430.01483154296875,"y":331.8860778808594},"p1":{"x":432.65155029296875,"y":331.8860778808594},"p2":{"x":435.28826904296875,"y":331.8860778808594},"p3":{"x":437.92498779296875,"y":331.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[36,37]},{"curve":{"p0":{"x":415.95623779296875,"y":331.8860778808594},"p1":{"x":417.9749755859375,"y":331.8860778808594},"p2":{"x":419.99371337890625,"y":331.8860778808594},"p3":{"x":422.10858154296875,"y":331.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[34,35]},{"curve":{"p0":{"x":422.10858154296875,"y":331.8860778808594},"p1":{"x":422.04449462890625,"y":331.8860778808594},"p2":{"x":422.0765380859375,"y":331.8860778808594},"p3":{"x":422.10858154296875,"y":331.8860778808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[35,35]},{"curve":{"p0":{"x":422.10858154296875,"y":331.8860778808594},"p1":{"x":424.70281982421875,"y":331.8860778808594},"p2":{"x":427.2970581054687,"y":331.8860778808594},"p3":{"x":430.01483154296875,"y":331.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[35,36]},{"curve":{"p0":{"x":430.01483154296875,"y":331.8860778808594},"p1":{"x":429.9324747721354,"y":331.8860778808594},"p2":{"x":429.97365315755206,"y":331.8860778808594},"p3":{"x":430.01483154296875,"y":331.8860778808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[36,36]},{"curve":{"p0":{"x":437.92498779296875,"y":331.8860778808594},"p1":{"x":440.26743570963544,"y":331.5931091308594},"p2":{"x":442.60988362630206,"y":331.3001403808594},"p3":{"x":444.95233154296875,"y":331.0071716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[37,38]},{"curve":{"p0":{"x":444.95233154296875,"y":331.0071716308594},"p1":{"x":446.41717529296875,"y":331.0071716308594},"p2":{"x":447.88201904296875,"y":331.0071716308594},"p3":{"x":449.34686279296875,"y":331.0071716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[38,39]},{"curve":{"p0":{"x":449.34686279296875,"y":331.0071716308594},"p1":{"x":450.81170654296875,"y":330.7155049641927},"p2":{"x":452.27655029296875,"y":330.42383829752606},"p3":{"x":453.74139404296875,"y":330.1321716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[39,40]},{"curve":{"p0":{"x":459.88983154296875,"y":330.1321716308594},"p1":{"x":461.06170654296875,"y":330.1321716308594},"p2":{"x":462.23358154296875,"y":330.1321716308594},"p3":{"x":463.40545654296875,"y":330.1321716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[41,42]},{"curve":{"p0":{"x":453.74139404296875,"y":330.1321716308594},"p1":{"x":455.75885009765625,"y":330.1321716308594},"p2":{"x":457.7763061523437,"y":330.1321716308594},"p3":{"x":459.88983154296875,"y":330.1321716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[40,41]},{"curve":{"p0":{"x":459.88983154296875,"y":330.1321716308594},"p1":{"x":459.8257853190104,"y":330.1321716308594},"p2":{"x":459.85780843098956,"y":330.1321716308594},"p3":{"x":459.88983154296875,"y":330.1321716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[41,41]},{"curve":{"p0":{"x":463.40545654296875,"y":330.1321716308594},"p1":{"x":464.87030029296875,"y":330.42383829752606},"p2":{"x":466.33514404296875,"y":330.7155049641927},"p3":{"x":467.79998779296875,"y":331.0071716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[42,43]},{"curve":{"p0":{"x":467.79998779296875,"y":331.0071716308594},"p1":{"x":468.97186279296875,"y":331.5931091308594},"p2":{"x":470.14373779296875,"y":332.1790466308594},"p3":{"x":471.31561279296875,"y":332.7649841308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[43,44]},{"curve":{"p0":{"x":471.31561279296875,"y":332.7649841308594},"p1":{"x":473.36509195963544,"y":334.8157653808594},"p2":{"x":475.41457112630206,"y":336.8665466308594},"p3":{"x":477.46405029296875,"y":338.9173278808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[44,45]},{"curve":{"p0":{"x":479.22186279296875,"y":342.4329528808594},"p1":{"x":479.80780029296875,"y":343.6048278808594},"p2":{"x":480.39373779296875,"y":344.7767028808594},"p3":{"x":480.97967529296875,"y":345.9485778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[46,47]},{"curve":{"p0":{"x":477.46405029296875,"y":338.9173278808594},"p1":{"x":478.03167724609375,"y":340.0525817871094},"p2":{"x":478.59930419921875,"y":341.1878356933594},"p3":{"x":479.22186279296875,"y":342.4329528808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[45,46]},{"curve":{"p0":{"x":479.22186279296875,"y":342.4329528808594},"p1":{"x":479.18524169921875,"y":342.3597106933594},"p2":{"x":479.20355224609375,"y":342.3963317871094},"p3":{"x":479.22186279296875,"y":342.4329528808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[46,46]},{"curve":{"p0":{"x":480.97967529296875,"y":345.9485778808594},"p1":{"x":481.27264404296875,"y":347.70508829752606},"p2":{"x":481.56561279296875,"y":349.4615987141927},"p3":{"x":481.85858154296875,"y":351.2181091308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[47,48]},{"curve":{"p0":{"x":481.85858154296875,"y":351.2181091308594},"p1":{"x":482.15155029296875,"y":352.6829528808594},"p2":{"x":482.44451904296875,"y":354.1477966308594},"p3":{"x":482.73748779296875,"y":355.6126403808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[48,49]},{"curve":{"p0":{"x":482.73748779296875,"y":355.6126403808594},"p1":{"x":483.03045654296875,"y":356.7845153808594},"p2":{"x":483.32342529296875,"y":357.9563903808594},"p3":{"x":483.61639404296875,"y":359.1282653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[49,50]},{"curve":{"p0":{"x":483.61639404296875,"y":359.1282653808594},"p1":{"x":483.61639404296875,"y":360.88477579752606},"p2":{"x":483.61639404296875,"y":362.6412862141927},"p3":{"x":483.61639404296875,"y":364.3977966308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[50,51]},{"curve":{"p0":{"x":483.61639404296875,"y":364.3977966308594},"p1":{"x":483.32342529296875,"y":365.5696716308594},"p2":{"x":483.03045654296875,"y":366.7415466308594},"p3":{"x":482.73748779296875,"y":367.9134216308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[51,52]},{"curve":{"p0":{"x":482.73748779296875,"y":367.9134216308594},"p1":{"x":481.85858154296875,"y":369.9642028808594},"p2":{"x":480.97967529296875,"y":372.0149841308594},"p3":{"x":480.10076904296875,"y":374.0657653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[52,53]},{"curve":{"p0":{"x":480.10076904296875,"y":374.0657653808594},"p1":{"x":479.51483154296875,"y":374.9446716308594},"p2":{"x":478.92889404296875,"y":375.8235778808594},"p3":{"x":478.34295654296875,"y":376.7024841308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[53,54]},{"curve":{"p0":{"x":478.34295654296875,"y":376.7024841308594},"p1":{"x":477.75701904296875,"y":377.58008829752606},"p2":{"x":477.17108154296875,"y":378.4576924641927},"p3":{"x":476.58514404296875,"y":379.3352966308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[54,55]},{"curve":{"p0":{"x":476.58514404296875,"y":379.3352966308594},"p1":{"x":475.99920654296875,"y":380.2142028808594},"p2":{"x":475.41326904296875,"y":381.0931091308594},"p3":{"x":474.82733154296875,"y":381.9720153808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[55,56]},{"curve":{"p0":{"x":474.82733154296875,"y":381.9720153808594},"p1":{"x":473.94972737630206,"y":382.5579528808594},"p2":{"x":473.07212320963544,"y":383.1438903808594},"p3":{"x":472.19451904296875,"y":383.7298278808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[56,57]},{"curve":{"p0":{"x":468.67889404296875,"y":385.4876403808594},"p1":{"x":467.50701904296875,"y":386.0735778808594},"p2":{"x":466.33514404296875,"y":386.6595153808594},"p3":{"x":465.16326904296875,"y":387.2454528808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[58,59]},{"curve":{"p0":{"x":472.19451904296875,"y":383.7298278808594},"p1":{"x":471.05926513671875,"y":384.2974548339844},"p2":{"x":469.92401123046875,"y":384.8650817871094},"p3":{"x":468.67889404296875,"y":385.4876403808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[57,58]},{"curve":{"p0":{"x":468.67889404296875,"y":385.4876403808594},"p1":{"x":468.75213623046875,"y":385.4510192871094},"p2":{"x":468.71551513671875,"y":385.4693298339844},"p3":{"x":468.67889404296875,"y":385.4876403808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[58,58]},{"curve":{"p0":{"x":465.16326904296875,"y":387.2454528808594},"p1":{"x":461.64894612630206,"y":387.2454528808594},"p2":{"x":458.13462320963544,"y":387.2454528808594},"p3":{"x":454.62030029296875,"y":387.2454528808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[59,60]},{"curve":{"p0":{"x":454.62030029296875,"y":387.2454528808594},"p1":{"x":452.86248779296875,"y":386.6595153808594},"p2":{"x":451.10467529296875,"y":386.0735778808594},"p3":{"x":449.34686279296875,"y":385.4876403808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[60,61]},{"curve":{"p0":{"x":449.34686279296875,"y":385.4876403808594},"p1":{"x":447.58905029296875,"y":384.6087341308594},"p2":{"x":445.83123779296875,"y":383.7298278808594},"p3":{"x":444.07342529296875,"y":382.8509216308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[61,62]},{"curve":{"p0":{"x":444.07342529296875,"y":382.8509216308594},"p1":{"x":442.31691487630206,"y":382.2649841308594},"p2":{"x":440.56040445963544,"y":381.6790466308594},"p3":{"x":438.80389404296875,"y":381.0931091308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[62,63]},{"curve":{"p0":{"x":438.80389404296875,"y":381.0931091308594},"p1":{"x":435.58123779296875,"y":380.8001403808594},"p2":{"x":432.35858154296875,"y":380.5071716308594},"p3":{"x":429.13592529296875,"y":380.2142028808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[63,64]},{"curve":{"p0":{"x":429.13592529296875,"y":380.2142028808594},"p1":{"x":426.50050862630206,"y":379.3352966308594},"p2":{"x":423.86509195963544,"y":378.4563903808594},"p3":{"x":421.22967529296875,"y":377.5774841308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[64,65]},{"curve":{"p0":{"x":421.22967529296875,"y":377.5774841308594},"p1":{"x":418.88592529296875,"y":376.4069112141927},"p2":{"x":416.54217529296875,"y":375.23633829752606},"p3":{"x":414.19842529296875,"y":374.0657653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[65,66]},{"curve":{"p0":{"x":414.19842529296875,"y":374.0657653808594},"p1":{"x":411.85597737630206,"y":372.0149841308594},"p2":{"x":409.51352945963544,"y":369.9642028808594},"p3":{"x":407.17108154296875,"y":367.9134216308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[66,67]},{"curve":{"p0":{"x":407.17108154296875,"y":367.9134216308594},"p1":{"x":405.70623779296875,"y":366.4485778808594},"p2":{"x":404.24139404296875,"y":364.9837341308594},"p3":{"x":402.77655029296875,"y":363.5188903808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[67,68]},{"curve":{"p0":{"x":402.77655029296875,"y":363.5188903808594},"p1":{"x":401.89764404296875,"y":362.6412862141927},"p2":{"x":401.01873779296875,"y":361.76368204752606},"p3":{"x":400.13983154296875,"y":360.8860778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[68,69]},{"curve":{"p0":{"x":400.13983154296875,"y":360.8860778808594},"p1":{"x":399.55389404296875,"y":360.0071716308594},"p2":{"x":398.96795654296875,"y":359.1282653808594},"p3":{"x":398.38201904296875,"y":358.2493591308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[69,70]},{"curve":{"p0":{"x":398.01873779296875,"y":339.4554862976074},"p1":{"x":398.01873779296875,"y":345.43713927528387},"p2":{"x":398.01873779296875,"y":351.4187922529603},"p3":{"x":398.38201904296875,"y":358.2493591308594}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[8,70]},{"curve":{"p0":{"x":398.38201904296875,"y":358.2493591308594},"p1":{"x":398.01873779296875,"y":360.31492694737767},"p2":{"x":398.01873779296875,"y":363.2294086641185},"p3":{"x":398.01873779296875,"y":366.1438903808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[70,2]},{"curve":{"p0":{"x":395.74920654296875,"y":352.0970153808594},"p1":{"x":395.17368570963544,"y":350.3743591308594},"p2":{"x":394.59816487630206,"y":348.6517028808594},"p3":{"x":394.02264404296875,"y":346.9290466308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[71,72]},{"curve":{"p0":{"x":394.02264404296875,"y":346.9290466308594},"p1":{"x":393.63332112630206,"y":345.7571716308594},"p2":{"x":393.24399820963544,"y":344.5852966308594},"p3":{"x":392.85467529296875,"y":343.4134216308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[72,73]},{"curve":{"p0":{"x":392.85467529296875,"y":343.4134216308594},"p1":{"x":391.50441487630206,"y":342.9381612141927},"p2":{"x":390.15415445963544,"y":342.46290079752606},"p3":{"x":388.80389404296875,"y":341.9876403808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[73,74]},{"curve":{"p0":{"x":388.80389404296875,"y":341.9876403808594},"p1":{"x":387.04608154296875,"y":341.9876403808594},"p2":{"x":385.28826904296875,"y":341.9876403808594},"p3":{"x":383.53045654296875,"y":341.9876403808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[74,75]},{"curve":{"p0":{"x":383.53045654296875,"y":341.9876403808594},"p1":{"x":382.06691487630206,"y":341.6946716308594},"p2":{"x":380.60337320963544,"y":341.4017028808594},"p3":{"x":379.13983154296875,"y":341.1087341308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[75,76]},{"curve":{"p0":{"x":379.13983154296875,"y":341.1087341308594},"p1":{"x":377.38201904296875,"y":341.4017028808594},"p2":{"x":375.62420654296875,"y":341.6946716308594},"p3":{"x":373.86639404296875,"y":341.9876403808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[76,77]},{"curve":{"p0":{"x":373.86639404296875,"y":341.9876403808594},"p1":{"x":372.40155029296875,"y":342.8665466308594},"p2":{"x":370.93670654296875,"y":343.7454528808594},"p3":{"x":369.47186279296875,"y":344.6243591308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[77,78]},{"curve":{"p0":{"x":369.47186279296875,"y":344.6243591308594},"p1":{"x":368.29998779296875,"y":345.5032653808594},"p2":{"x":367.12811279296875,"y":346.3821716308594},"p3":{"x":365.95623779296875,"y":347.2610778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[78,79]},{"curve":{"p0":{"x":365.95623779296875,"y":347.2610778808594},"p1":{"x":365.07863362630206,"y":348.43165079752606},"p2":{"x":364.20102945963544,"y":349.6022237141927},"p3":{"x":363.32342529296875,"y":350.7727966308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[79,80]},{"curve":{"p0":{"x":360.68670654296875,"y":353.4095153808594},"p1":{"x":359.80780029296875,"y":354.2884216308594},"p2":{"x":358.92889404296875,"y":355.1673278808594},"p3":{"x":358.04998779296875,"y":356.0462341308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[81,82]},{"curve":{"p0":{"x":363.32342529296875,"y":350.7727966308594},"p1":{"x":362.47198486328125,"y":351.6242370605469},"p2":{"x":361.62054443359375,"y":352.4756774902344},"p3":{"x":360.68670654296875,"y":353.4095153808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[80,81]},{"curve":{"p0":{"x":360.68670654296875,"y":353.4095153808594},"p1":{"x":360.74163818359375,"y":353.3545837402344},"p2":{"x":360.71417236328125,"y":353.3820495605469},"p3":{"x":360.68670654296875,"y":353.4095153808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[81,81]},{"curve":{"p0":{"x":360.68670654296875,"y":353.4095153808594},"p1":{"x":360.70428466796875,"y":353.3919372558594},"p2":{"x":360.69549560546875,"y":353.4007263183594},"p3":{"x":360.68670654296875,"y":353.4095153808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[81,81]},{"curve":{"p0":{"x":360.68670654296875,"y":353.4095153808594},"p1":{"x":360.82534691595265,"y":353.2708750078755},"p2":{"x":360.7692103232107,"y":353.3270116006174},"p3":{"x":360.68670654296875,"y":353.4095153808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[81,81]},{"curve":{"p0":{"x":358.04998779296875,"y":356.0462341308594},"p1":{"x":357.17108154296875,"y":357.5110778808594},"p2":{"x":356.29217529296875,"y":358.9759216308594},"p3":{"x":355.41326904296875,"y":360.4407653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[82,83]},{"curve":{"p0":{"x":355.41326904296875,"y":360.4407653808594},"p1":{"x":354.53436279296875,"y":361.90430704752606},"p2":{"x":353.65545654296875,"y":363.3678487141927},"p3":{"x":352.77655029296875,"y":364.8313903808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[83,84]},{"curve":{"p0":{"x":398.01873779296875,"y":366.1438903808594},"p1":{"x":382.93800862630206,"y":366.1438903808594},"p2":{"x":367.8572794596354,"y":366.1438903808594},"p3":{"x":352.7765502929687,"y":366.1438903808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[2,85]},{"curve":{"p0":{"x":276.17498779296875,"y":371.8352966308594},"p1":{"x":276.17498779296875,"y":370.3933410644531},"p2":{"x":276.17498779296875,"y":368.9513854980469},"p3":{"x":276.17498779296875,"y":367.4407653808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[133,134]},{"curve":{"p0":{"x":352.77655029296875,"y":366.1438903808594},"p1":{"x":352.77655029296875,"y":368.0462341308594},"p2":{"x":352.77655029296875,"y":369.9485778808594},"p3":{"x":352.77655029296875,"y":371.8509216308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[85,86]},{"curve":{"p0":{"x":352.77655029296875,"y":371.8509216308594},"p1":{"x":353.65545654296875,"y":373.3157653808594},"p2":{"x":354.53436279296875,"y":374.7806091308594},"p3":{"x":355.41326904296875,"y":376.2454528808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[86,87]},{"curve":{"p0":{"x":355.41326904296875,"y":376.2454528808594},"p1":{"x":355.99660237630206,"y":377.1204528808594},"p2":{"x":356.57993570963544,"y":377.9954528808594},"p3":{"x":357.16326904296875,"y":378.8704528808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[87,88]},{"curve":{"p0":{"x":357.16326904296875,"y":378.8704528808594},"p1":{"x":358.04217529296875,"y":379.74805704752606},"p2":{"x":358.92108154296875,"y":380.6256612141927},"p3":{"x":359.79998779296875,"y":381.5032653808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[88,89]},{"curve":{"p0":{"x":359.79998779296875,"y":381.5032653808594},"p1":{"x":361.84946695963544,"y":381.7962341308594},"p2":{"x":363.89894612630206,"y":382.0892028808594},"p3":{"x":365.94842529296875,"y":382.3821716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[89,90]},{"curve":{"p0":{"x":379.12811279296875,"y":382.3821716308594},"p1":{"x":381.47186279296875,"y":382.3821716308594},"p2":{"x":383.81561279296875,"y":382.3821716308594},"p3":{"x":386.15936279296875,"y":382.3821716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[93,94]},{"curve":{"p0":{"x":365.94842529296875,"y":382.3821716308594},"p1":{"x":367.390380859375,"y":382.3821716308594},"p2":{"x":368.83233642578125,"y":382.3821716308594},"p3":{"x":370.34295654296875,"y":382.3821716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[90,91]},{"curve":{"p0":{"x":370.34295654296875,"y":382.3821716308594},"p1":{"x":370.29718017578125,"y":382.3821716308594},"p2":{"x":370.320068359375,"y":382.3821716308594},"p3":{"x":370.34295654296875,"y":382.3821716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[91,91]},{"curve":{"p0":{"x":370.34295654296875,"y":382.3821716308594},"p1":{"x":371.784912109375,"y":382.3821716308594},"p2":{"x":373.22686767578125,"y":382.3821716308594},"p3":{"x":374.73748779296875,"y":382.3821716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[91,92]},{"curve":{"p0":{"x":374.73748779296875,"y":382.3821716308594},"p1":{"x":374.69171142578125,"y":382.3821716308594},"p2":{"x":374.714599609375,"y":382.3821716308594},"p3":{"x":374.73748779296875,"y":382.3821716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[92,92]},{"curve":{"p0":{"x":374.73748779296875,"y":382.3821716308594},"p1":{"x":376.17816162109375,"y":382.3821716308594},"p2":{"x":377.6188354492187,"y":382.3821716308594},"p3":{"x":379.12811279296875,"y":382.3821716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[92,93]},{"curve":{"p0":{"x":379.12811279296875,"y":382.3821716308594},"p1":{"x":379.0823771158854,"y":382.3821716308594},"p2":{"x":379.10524495442706,"y":382.3821716308594},"p3":{"x":379.12811279296875,"y":382.3821716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[93,93]},{"curve":{"p0":{"x":386.15936279296875,"y":382.3821716308594},"p1":{"x":387.91587320963544,"y":382.9681091308594},"p2":{"x":389.67238362630206,"y":383.5540466308594},"p3":{"x":391.42889404296875,"y":384.1399841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[94,95]},{"curve":{"p0":{"x":398.01873779296875,"y":402.0149841308594},"p1":{"x":398.01873779296875,"y":397.0329239456742},"p2":{"x":398.01873779296875,"y":392.05086376048905},"p3":{"x":398.01873779296875,"y":387.0688035753039}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[7,96]},{"curve":{"p0":{"x":398.01873779296875,"y":387.0688035753039},"p1":{"x":398.01873779296875,"y":380.09383251048905},"p2":{"x":398.01873779296875,"y":373.1188614456742},"p3":{"x":398.01873779296875,"y":366.1438903808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[96,2]},{"curve":{"p0":{"x":398.01873779296875,"y":387.06880357530383},"p1":{"x":398.4588419596354,"y":387.2644054271557},"p2":{"x":398.89894612630206,"y":387.4600072790075},"p3":{"x":399.33905029296875,"y":387.6556091308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[96,97]},{"curve":{"p0":{"x":399.33905029296875,"y":387.6556091308594},"p1":{"x":401.68149820963544,"y":388.8274841308594},"p2":{"x":404.02394612630206,"y":389.9993591308594},"p3":{"x":406.36639404296875,"y":391.1712341308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[97,98]},{"curve":{"p0":{"x":406.36639404296875,"y":391.1712341308594},"p1":{"x":409.29608154296875,"y":393.51368204752606},"p2":{"x":412.22576904296875,"y":395.8561299641927},"p3":{"x":415.15545654296875,"y":398.1985778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[98,99]},{"curve":{"p0":{"x":415.15545654296875,"y":398.1985778808594},"p1":{"x":416.62030029296875,"y":399.0774841308594},"p2":{"x":418.08514404296875,"y":399.9563903808594},"p3":{"x":419.54998779296875,"y":400.8352966308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[99,100]},{"curve":{"p0":{"x":520.5656127929688,"y":402.0149841308594},"p1":{"x":487.3848373300058,"y":402.0149841308594},"p2":{"x":454.2040618670428,"y":402.0149841308594},"p3":{"x":421.02328640407984,"y":402.0149841308594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[6,101]},{"curve":{"p0":{"x":421.02328640407984,"y":402.0149841308594},"p1":{"x":413.3551035337095,"y":402.0149841308594},"p2":{"x":405.68692066333915,"y":402.0149841308594},"p3":{"x":398.01873779296875,"y":402.0149841308594}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[101,7]},{"curve":{"p0":{"x":421.02328640407984,"y":402.0149841308594},"p1":{"x":421.9957285337095,"y":402.79362996419275},"p2":{"x":422.9681706633391,"y":403.57227579752606},"p3":{"x":423.94061279296875,"y":404.3509216308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[101,102]},{"curve":{"p0":{"x":423.94061279296875,"y":404.3509216308594},"p1":{"x":425.11248779296875,"y":404.9368591308594},"p2":{"x":426.28436279296875,"y":405.5227966308594},"p3":{"x":427.45623779296875,"y":406.1087341308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[102,103]},{"curve":{"p0":{"x":427.45623779296875,"y":406.1087341308594},"p1":{"x":428.91977945963544,"y":406.1087341308594},"p2":{"x":430.38332112630206,"y":406.1087341308594},"p3":{"x":431.84686279296875,"y":406.1087341308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[103,104]},{"curve":{"p0":{"x":431.84686279296875,"y":406.1087341308594},"p1":{"x":433.01743570963544,"y":406.40040079752606},"p2":{"x":434.18800862630206,"y":406.6920674641927},"p3":{"x":435.35858154296875,"y":406.9837341308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[104,105]},{"curve":{"p0":{"x":435.35858154296875,"y":406.9837341308594},"p1":{"x":436.48358154296875,"y":407.56836954752606},"p2":{"x":437.60858154296875,"y":408.1530049641927},"p3":{"x":438.73358154296875,"y":408.7376403808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[105,106]},{"curve":{"p0":{"x":438.73358154296875,"y":408.7376403808594},"p1":{"x":439.61248779296875,"y":409.6165466308594},"p2":{"x":440.49139404296875,"y":410.4954528808594},"p3":{"x":441.37030029296875,"y":411.3743591308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[106,107]},{"curve":{"p0":{"x":441.37030029296875,"y":421.9212341308594},"p1":{"x":441.37030029296875,"y":423.09180704752606},"p2":{"x":441.37030029296875,"y":424.2623799641927},"p3":{"x":441.37030029296875,"y":425.4329528808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[109,110]},{"curve":{"p0":{"x":441.37030029296875,"y":411.3743591308594},"p1":{"x":441.37030029296875,"y":413.1047058105469},"p2":{"x":441.37030029296875,"y":414.8350524902344},"p3":{"x":441.37030029296875,"y":416.6477966308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[107,108]},{"curve":{"p0":{"x":441.37030029296875,"y":416.6477966308594},"p1":{"x":441.37030029296875,"y":416.5928649902344},"p2":{"x":441.37030029296875,"y":416.6203308105469},"p3":{"x":441.37030029296875,"y":416.6477966308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[108,108]},{"curve":{"p0":{"x":441.37030029296875,"y":416.6477966308594},"p1":{"x":441.37030029296875,"y":418.3781433105469},"p2":{"x":441.37030029296875,"y":420.1084899902344},"p3":{"x":441.37030029296875,"y":421.9212341308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[108,109]},{"curve":{"p0":{"x":441.37030029296875,"y":421.9212341308594},"p1":{"x":441.37030029296875,"y":421.8663024902344},"p2":{"x":441.37030029296875,"y":421.8937683105469},"p3":{"x":441.37030029296875,"y":421.9212341308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[109,109]},{"curve":{"p0":{"x":441.37030029296875,"y":425.4329528808594},"p1":{"x":440.49139404296875,"y":426.3118591308594},"p2":{"x":439.61248779296875,"y":427.1907653808594},"p3":{"x":438.73358154296875,"y":428.0696716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[110,111]},{"curve":{"p0":{"x":438.73358154296875,"y":428.0696716308594},"p1":{"x":437.26873779296875,"y":428.6556091308594},"p2":{"x":435.80389404296875,"y":429.2415466308594},"p3":{"x":434.33905029296875,"y":429.8274841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[111,112]},{"curve":{"p0":{"x":429.06951904296875,"y":429.8274841308594},"p1":{"x":427.60467529296875,"y":429.8274841308594},"p2":{"x":426.13983154296875,"y":429.8274841308594},"p3":{"x":424.67498779296875,"y":429.8274841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[113,114]},{"curve":{"p0":{"x":434.33905029296875,"y":429.8274841308594},"p1":{"x":432.6099853515625,"y":429.8274841308594},"p2":{"x":430.8809204101563,"y":429.8274841308594},"p3":{"x":429.06951904296875,"y":429.8274841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[112,113]},{"curve":{"p0":{"x":429.06951904296875,"y":429.8274841308594},"p1":{"x":429.1244099934896,"y":429.8274841308594},"p2":{"x":429.0969645182292,"y":429.8274841308594},"p3":{"x":429.06951904296875,"y":429.8274841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[113,113]},{"curve":{"p0":{"x":424.67498779296875,"y":429.8274841308594},"p1":{"x":423.21014404296875,"y":430.4134216308594},"p2":{"x":421.74530029296875,"y":430.9993591308594},"p3":{"x":420.28045654296875,"y":431.5852966308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[114,115]},{"curve":{"p0":{"x":420.28045654296875,"y":431.5852966308594},"p1":{"x":416.76613362630206,"y":432.4642028808594},"p2":{"x":413.25181070963544,"y":433.3431091308594},"p3":{"x":409.73748779296875,"y":434.2220153808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[115,116]},{"curve":{"p0":{"x":409.73748779296875,"y":434.2220153808594},"p1":{"x":407.10076904296875,"y":435.1009216308594},"p2":{"x":404.46405029296875,"y":435.9798278808594},"p3":{"x":401.82733154296875,"y":436.8587341308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[116,117]},{"curve":{"p0":{"x":401.82733154296875,"y":436.8587341308594},"p1":{"x":399.19191487630206,"y":437.1517028808594},"p2":{"x":396.55649820963544,"y":437.4446716308594},"p3":{"x":393.92108154296875,"y":437.7376403808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[117,118]},{"curve":{"p0":{"x":393.92108154296875,"y":437.7376403808594},"p1":{"x":390.40675862630206,"y":438.0306091308594},"p2":{"x":386.89243570963544,"y":438.3235778808594},"p3":{"x":383.37811279296875,"y":438.6165466308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[118,119]},{"curve":{"p0":{"x":383.37811279296875,"y":438.6165466308594},"p1":{"x":381.32733154296875,"y":438.6165466308594},"p2":{"x":379.27655029296875,"y":438.6165466308594},"p3":{"x":377.22576904296875,"y":438.6165466308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[119,120]},{"curve":{"p0":{"x":377.22576904296875,"y":438.6165466308594},"p1":{"x":374.29738362630206,"y":438.6165466308594},"p2":{"x":371.36899820963544,"y":438.6165466308594},"p3":{"x":368.44061279296875,"y":438.6165466308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[120,121]},{"curve":{"p0":{"x":368.44061279296875,"y":438.6165466308594},"p1":{"x":365.80389404296875,"y":438.0306091308594},"p2":{"x":363.16717529296875,"y":437.4446716308594},"p3":{"x":360.53045654296875,"y":436.8587341308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[121,122]},{"curve":{"p0":{"x":360.53045654296875,"y":436.8587341308594},"p1":{"x":357.89503987630206,"y":435.9798278808594},"p2":{"x":355.25962320963544,"y":435.1009216308594},"p3":{"x":352.62420654296875,"y":434.2220153808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[122,123]},{"curve":{"p0":{"x":352.62420654296875,"y":434.2220153808594},"p1":{"x":349.69451904296875,"y":433.0501403808594},"p2":{"x":346.76483154296875,"y":431.8782653808594},"p3":{"x":343.83514404296875,"y":430.7063903808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[123,124]},{"curve":{"p0":{"x":343.83514404296875,"y":430.7063903808594},"p1":{"x":339.73488362630206,"y":428.9485778808594},"p2":{"x":335.63462320963544,"y":427.1907653808594},"p3":{"x":331.53436279296875,"y":425.4329528808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[124,125]},{"curve":{"p0":{"x":331.53436279296875,"y":425.4329528808594},"p1":{"x":329.19061279296875,"y":424.2623799641927},"p2":{"x":326.84686279296875,"y":423.09180704752606},"p3":{"x":324.50311279296875,"y":421.9212341308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[125,126]},{"curve":{"p0":{"x":324.50311279296875,"y":421.9212341308594},"p1":{"x":322.16066487630206,"y":420.7493591308594},"p2":{"x":319.81821695963544,"y":419.5774841308594},"p3":{"x":317.47576904296875,"y":418.4056091308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[126,127]},{"curve":{"p0":{"x":317.47576904296875,"y":418.4056091308594},"p1":{"x":314.54608154296875,"y":416.0618591308594},"p2":{"x":311.61639404296875,"y":413.7181091308594},"p3":{"x":308.68670654296875,"y":411.3743591308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[127,128]},{"curve":{"p0":{"x":308.68670654296875,"y":411.3743591308594},"p1":{"x":305.75832112630206,"y":408.7389424641927},"p2":{"x":302.82993570963544,"y":406.10352579752606},"p3":{"x":299.90155029296875,"y":403.4681091308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[128,129]},{"curve":{"p0":{"x":299.90155029296875,"y":403.4681091308594},"p1":{"x":297.55780029296875,"y":401.1243591308594},"p2":{"x":295.21405029296875,"y":398.7806091308594},"p3":{"x":292.87030029296875,"y":396.4368591308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[129,130]},{"curve":{"p0":{"x":292.87030029296875,"y":396.4368591308594},"p1":{"x":289.64894612630206,"y":392.9225362141927},"p2":{"x":286.42759195963544,"y":389.40821329752606},"p3":{"x":283.20623779296875,"y":385.8938903808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[130,131]},{"curve":{"p0":{"x":283.20623779296875,"y":385.8938903808594},"p1":{"x":282.03436279296875,"y":383.8431091308594},"p2":{"x":280.86248779296875,"y":381.7923278808594},"p3":{"x":279.69061279296875,"y":379.7415466308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[131,132]},{"curve":{"p0":{"x":279.69061279296875,"y":379.7415466308594},"p1":{"x":278.51873779296875,"y":377.1061299641927},"p2":{"x":277.34686279296875,"y":374.47071329752606},"p3":{"x":276.17498779296875,"y":371.8352966308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[132,133]},{"curve":{"p0":{"x":276.17498779296875,"y":367.4407653808594},"p1":{"x":276.17498779296875,"y":367.0084737141927},"p2":{"x":276.17498779296875,"y":366.576182047526},"p3":{"x":276.17498779296875,"y":366.1438903808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[134,135]},{"curve":{"p0":{"x":352.7765502929687,"y":366.1438903808594},"p1":{"x":327.24269612630206,"y":366.1438903808594},"p2":{"x":301.70884195963544,"y":366.1438903808594},"p3":{"x":276.17498779296875,"y":366.1438903808594}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[85,135]},{"curve":{"p0":{"x":276.17498779296875,"y":366.1438903808594},"p1":{"x":248.60858154296875,"y":366.1438903808594},"p2":{"x":221.04217529296875,"y":366.1438903808594},"p3":{"x":193.47576904296875,"y":366.1438903808594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[135,3]},{"curve":{"p0":{"x":276.17498779296875,"y":367.4407653808594},"p1":{"x":276.17498779296875,"y":367.4865417480469},"p2":{"x":276.17498779296875,"y":367.4636535644531},"p3":{"x":276.17498779296875,"y":367.4407653808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[134,134]},{"curve":{"p0":{"x":276.17498779296875,"y":366.1438903808594},"p1":{"x":276.17498779296875,"y":365.404307047526},"p2":{"x":276.17498779296875,"y":364.6647237141927},"p3":{"x":276.17498779296875,"y":363.9251403808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[135,136]},{"curve":{"p0":{"x":276.17498779296875,"y":363.9251403808594},"p1":{"x":276.46795654296875,"y":362.1686299641927},"p2":{"x":276.76092529296875,"y":360.41211954752606},"p3":{"x":277.05389404296875,"y":358.6556091308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[136,137]},{"curve":{"p0":{"x":277.05389404296875,"y":358.6556091308594},"p1":{"x":277.93280029296875,"y":356.8977966308594},"p2":{"x":278.81170654296875,"y":355.1399841308594},"p3":{"x":279.69061279296875,"y":353.3821716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[137,138]},{"curve":{"p0":{"x":279.69061279296875,"y":353.3821716308594},"p1":{"x":280.56951904296875,"y":352.7962341308594},"p2":{"x":281.44842529296875,"y":352.2102966308594},"p3":{"x":282.32733154296875,"y":351.6243591308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[138,139]},{"curve":{"p0":{"x":282.32733154296875,"y":351.6243591308594},"p1":{"x":283.49920654296875,"y":350.4524841308594},"p2":{"x":284.67108154296875,"y":349.2806091308594},"p3":{"x":285.84295654296875,"y":348.1087341308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[139,140]},{"curve":{"p0":{"x":285.84295654296875,"y":348.1087341308594},"p1":{"x":286.13592529296875,"y":346.74415079752606},"p2":{"x":286.42889404296875,"y":345.3795674641927},"p3":{"x":286.72186279296875,"y":344.0149841308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[140,141]},{"curve":{"p0":{"x":286.72186279296875,"y":344.0149841308594},"p1":{"x":286.13592529296875,"y":343.1360778808594},"p2":{"x":285.54998779296875,"y":342.2571716308594},"p3":{"x":284.96405029296875,"y":341.3782653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[141,142]},{"curve":{"p0":{"x":284.96405029296875,"y":341.3782653808594},"p1":{"x":283.79217529296875,"y":340.4993591308594},"p2":{"x":282.62030029296875,"y":339.6204528808594},"p3":{"x":281.44842529296875,"y":338.7415466308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[142,143]},{"curve":{"p0":{"x":281.44842529296875,"y":338.7415466308594},"p1":{"x":279.69061279296875,"y":338.1556091308594},"p2":{"x":277.93280029296875,"y":337.5696716308594},"p3":{"x":276.17498779296875,"y":336.9837341308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[143,144]},{"curve":{"p0":{"x":276.17498779296875,"y":336.9837341308594},"p1":{"x":273.24660237630206,"y":336.3977966308594},"p2":{"x":270.31821695963544,"y":335.8118591308594},"p3":{"x":267.38983154296875,"y":335.2259216308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[144,145]},{"curve":{"p0":{"x":267.38983154296875,"y":335.2259216308594},"p1":{"x":266.21795654296875,"y":334.9329528808594},"p2":{"x":265.04608154296875,"y":334.6399841308594},"p3":{"x":263.87420654296875,"y":334.3470153808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[145,146]},{"curve":{"p0":{"x":263.87420654296875,"y":334.3470153808594},"p1":{"x":262.70233154296875,"y":333.7610778808594},"p2":{"x":261.53045654296875,"y":333.1751403808594},"p3":{"x":260.35858154296875,"y":332.5892028808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[146,147]},{"curve":{"p0":{"x":260.35858154296875,"y":332.5892028808594},"p1":{"x":259.18800862630206,"y":332.2962341308594},"p2":{"x":258.01743570963544,"y":332.0032653808594},"p3":{"x":256.84686279296875,"y":331.7102966308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[147,148]},{"curve":{"p0":{"x":256.84686279296875,"y":331.7102966308594},"p1":{"x":254.21014404296875,"y":330.8326924641927},"p2":{"x":251.57342529296875,"y":329.95508829752606},"p3":{"x":248.93670654296875,"y":329.0774841308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[148,149]},{"curve":{"p0":{"x":248.93670654296875,"y":329.0774841308594},"p1":{"x":247.17889404296875,"y":328.7845153808594},"p2":{"x":245.42108154296875,"y":328.4915466308594},"p3":{"x":243.66326904296875,"y":328.1985778808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[149,150]},{"curve":{"p0":{"x":243.66326904296875,"y":328.1985778808594},"p1":{"x":240.7348836263021,"y":327.6126403808594},"p2":{"x":237.8064982096354,"y":327.0267028808594},"p3":{"x":234.87811279296875,"y":326.4407653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[150,151]},{"curve":{"p0":{"x":226.96795654296875,"y":326.4407653808594},"p1":{"x":225.2114461263021,"y":326.4407653808594},"p2":{"x":223.4549357096354,"y":326.4407653808594},"p3":{"x":221.69842529296875,"y":326.4407653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[153,154]},{"curve":{"p0":{"x":234.87811279296875,"y":326.4407653808594},"p1":{"x":233.72454833984375,"y":326.4407653808594},"p2":{"x":232.57098388671875,"y":326.4407653808594},"p3":{"x":231.36248779296875,"y":326.4407653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[151,152]},{"curve":{"p0":{"x":231.36248779296875,"y":326.4407653808594},"p1":{"x":231.39910888671875,"y":326.4407653808594},"p2":{"x":231.38079833984375,"y":326.4407653808594},"p3":{"x":231.36248779296875,"y":326.4407653808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[152,152]},{"curve":{"p0":{"x":231.36248779296875,"y":326.4407653808594},"p1":{"x":229.9205322265625,"y":326.4407653808594},"p2":{"x":228.47857666015625,"y":326.4407653808594},"p3":{"x":226.96795654296875,"y":326.4407653808594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[152,153]},{"curve":{"p0":{"x":226.96795654296875,"y":326.4407653808594},"p1":{"x":227.01373291015625,"y":326.4407653808594},"p2":{"x":226.9908447265625,"y":326.4407653808594},"p3":{"x":226.96795654296875,"y":326.4407653808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[153,153]},{"curve":{"p0":{"x":221.69842529296875,"y":326.4407653808594},"p1":{"x":219.06170654296875,"y":326.7337341308594},"p2":{"x":216.42498779296875,"y":327.0267028808594},"p3":{"x":213.78826904296875,"y":327.3196716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[154,155]},{"curve":{"p0":{"x":194.06561279296875,"y":327.3196716308594},"p1":{"x":192.89373779296875,"y":327.3196716308594},"p2":{"x":191.72186279296875,"y":327.3196716308594},"p3":{"x":190.54998779296875,"y":327.3196716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[160,161]},{"curve":{"p0":{"x":213.78826904296875,"y":327.3196716308594},"p1":{"x":212.0592041015625,"y":327.3196716308594},"p2":{"x":210.33013916015622,"y":327.3196716308594},"p3":{"x":208.51873779296875,"y":327.3196716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[155,156]},{"curve":{"p0":{"x":208.51873779296875,"y":327.3196716308594},"p1":{"x":208.57362874348956,"y":327.3196716308594},"p2":{"x":208.54618326822916,"y":327.3196716308594},"p3":{"x":208.51873779296875,"y":327.3196716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[156,156]},{"curve":{"p0":{"x":208.51873779296875,"y":327.3196716308594},"p1":{"x":207.36517333984375,"y":327.3196716308594},"p2":{"x":206.21160888671875,"y":327.3196716308594},"p3":{"x":205.00311279296875,"y":327.3196716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[156,157]},{"curve":{"p0":{"x":205.00311279296875,"y":327.3196716308594},"p1":{"x":205.03973388671875,"y":327.3196716308594},"p2":{"x":205.02142333984375,"y":327.3196716308594},"p3":{"x":205.00311279296875,"y":327.3196716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[157,157]},{"curve":{"p0":{"x":205.00311279296875,"y":327.3196716308594},"p1":{"x":203.6329345703125,"y":327.3196716308594},"p2":{"x":202.26275634765628,"y":327.3196716308594},"p3":{"x":200.82733154296875,"y":327.3196716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[157,158]},{"curve":{"p0":{"x":200.82733154296875,"y":327.3196716308594},"p1":{"x":200.87082926432294,"y":327.3196716308594},"p2":{"x":200.84908040364584,"y":327.3196716308594},"p3":{"x":200.82733154296875,"y":327.3196716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[158,158]},{"curve":{"p0":{"x":200.82733154296875,"y":327.3196716308594},"p1":{"x":199.70343017578125,"y":327.3196716308594},"p2":{"x":198.57952880859375,"y":327.3196716308594},"p3":{"x":197.34686279296875,"y":327.3196716308594}},"deleted":false,"stroke_color":null,"stroke_style":null,"vertices":[158,159]},{"curve":{"p0":{"x":197.34686279296875,"y":327.3196716308594},"p1":{"x":197.41937255859375,"y":327.3196716308594},"p2":{"x":197.38311767578125,"y":327.3196716308594},"p3":{"x":197.34686279296875,"y":327.3196716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[159,159]},{"curve":{"p0":{"x":193.47576904296875,"y":366.1438903808594},"p1":{"x":193.47576904296875,"y":353.2024841308594},"p2":{"x":193.47576904296875,"y":340.2610778808594},"p3":{"x":194.06561279296875,"y":327.3196716308594}},"deleted":false,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[3,160]},{"curve":{"p0":{"x":194.06561279296875,"y":327.3196716308594},"p1":{"x":193.47576904296875,"y":318.7243830306424},"p2":{"x":193.47576904296875,"y":310.1290944304254},"p3":{"x":193.47576904296875,"y":301.53380583020845}},"deleted":true,"stroke_color":{"a":255,"b":0,"g":0,"r":0},"stroke_style":{"cap":"Round","join":"Miter","miter_limit":4.0,"width":3.0},"vertices":[160,12]},{"curve":{"p0":{"x":194.06561279296875,"y":327.3196716308594},"p1":{"x":194.13397216796875,"y":327.3196716308594},"p2":{"x":194.09979248046875,"y":327.3196716308594},"p3":{"x":194.06561279296875,"y":327.3196716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[160,160]},{"curve":{"p0":{"x":190.54998779296875,"y":327.3196716308594},"p1":{"x":188.7934773763021,"y":327.6126403808594},"p2":{"x":187.0369669596354,"y":327.9056091308594},"p3":{"x":185.28045654296875,"y":328.1985778808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[161,162]},{"curve":{"p0":{"x":185.28045654296875,"y":328.1985778808594},"p1":{"x":182.35076904296875,"y":328.4915466308594},"p2":{"x":179.42108154296875,"y":328.7845153808594},"p3":{"x":176.49139404296875,"y":329.0774841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[162,163]},{"curve":{"p0":{"x":158.91717529296875,"y":329.0774841308594},"p1":{"x":157.7466023763021,"y":329.0774841308594},"p2":{"x":156.5760294596354,"y":329.0774841308594},"p3":{"x":155.40545654296875,"y":329.0774841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[166,167]},{"curve":{"p0":{"x":176.49139404296875,"y":329.0774841308594},"p1":{"x":174.7623291015625,"y":329.0774841308594},"p2":{"x":173.03326416015622,"y":329.0774841308594},"p3":{"x":171.22186279296875,"y":329.0774841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[163,164]},{"curve":{"p0":{"x":171.22186279296875,"y":329.0774841308594},"p1":{"x":171.27675374348956,"y":329.0774841308594},"p2":{"x":171.24930826822916,"y":329.0774841308594},"p3":{"x":171.22186279296875,"y":329.0774841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[164,164]},{"curve":{"p0":{"x":171.22186279296875,"y":329.0774841308594},"p1":{"x":169.49151611328125,"y":329.0774841308594},"p2":{"x":167.76116943359375,"y":329.0774841308594},"p3":{"x":165.94842529296875,"y":329.0774841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[164,165]},{"curve":{"p0":{"x":165.94842529296875,"y":329.0774841308594},"p1":{"x":166.00335693359375,"y":329.0774841308594},"p2":{"x":165.97589111328125,"y":329.0774841308594},"p3":{"x":165.94842529296875,"y":329.0774841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[165,165]},{"curve":{"p0":{"x":165.94842529296875,"y":329.0774841308594},"p1":{"x":163.64129638671875,"y":329.0774841308594},"p2":{"x":161.33416748046875,"y":329.0774841308594},"p3":{"x":158.91717529296875,"y":329.0774841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[165,166]},{"curve":{"p0":{"x":158.91717529296875,"y":329.0774841308594},"p1":{"x":158.99041748046875,"y":329.0774841308594},"p2":{"x":158.95379638671875,"y":329.0774841308594},"p3":{"x":158.91717529296875,"y":329.0774841308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[166,166]},{"curve":{"p0":{"x":155.40545654296875,"y":329.0774841308594},"p1":{"x":154.23358154296875,"y":328.7845153808594},"p2":{"x":153.06170654296875,"y":328.4915466308594},"p3":{"x":151.88983154296875,"y":328.1985778808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[167,168]},{"curve":{"p0":{"x":151.88983154296875,"y":328.1985778808594},"p1":{"x":150.7400919596354,"y":327.9056091308594},"p2":{"x":149.5903523763021,"y":327.6126403808594},"p3":{"x":148.44061279296875,"y":327.3196716308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[168,169]},{"curve":{"p0":{"x":148.44061279296875,"y":327.3196716308594},"p1":{"x":146.9770711263021,"y":326.7337341308594},"p2":{"x":145.5135294596354,"y":326.1477966308594},"p3":{"x":144.04998779296875,"y":325.5618591308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[169,170]},{"curve":{"p0":{"x":144.04998779296875,"y":325.5618591308594},"p1":{"x":142.9380086263021,"y":324.9954528808594},"p2":{"x":141.8260294596354,"y":324.4290466308594},"p3":{"x":140.71405029296875,"y":323.8626403808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[170,171]},{"curve":{"p0":{"x":140.71405029296875,"y":323.8626403808594},"p1":{"x":139.83514404296875,"y":322.9850362141927},"p2":{"x":138.95623779296875,"y":322.10743204752606},"p3":{"x":138.07733154296875,"y":321.2298278808594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[171,172]},{"curve":{"p0":{"x":138.07733154296875,"y":321.2298278808594},"p1":{"x":148.6932169596354,"y":316.2884216308594},"p2":{"x":159.3091023763021,"y":311.3470153808594},"p3":{"x":169.92498779296875,"y":306.4056091308594}},"deleted":true,"stroke_color":null,"stroke_style":null,"vertices":[172,9]}],"fills":[{"boundary":[[7,"Backward"],[117,"Backward"],[116,"Backward"],[115,"Backward"],[114,"Backward"],[113,"Forward"],[80,"Backward"],[78,"Backward"],[77,"Backward"],[76,"Backward"],[75,"Backward"],[74,"Backward"],[73,"Backward"],[72,"Backward"],[71,"Backward"],[70,"Backward"],[69,"Backward"],[68,"Backward"],[65,"Backward"],[66,"Backward"],[64,"Backward"],[63,"Backward"],[62,"Backward"],[61,"Backward"],[60,"Backward"],[59,"Backward"],[58,"Backward"],[57,"Backward"],[56,"Backward"],[55,"Backward"],[52,"Backward"],[53,"Backward"],[51,"Backward"],[50,"Backward"],[49,"Backward"],[46,"Backward"],[47,"Backward"],[45,"Backward"],[44,"Backward"],[43,"Backward"],[38,"Backward"],[41,"Backward"],[39,"Backward"],[37,"Backward"],[34,"Backward"],[35,"Backward"],[32,"Backward"],[1,"Forward"],[6,"Forward"],[118,"Forward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":false,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null},{"boundary":[[7,"Forward"],[119,"Forward"],[112,"Forward"],[114,"Forward"],[115,"Forward"],[116,"Forward"],[117,"Forward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":true,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null},{"boundary":[[80,"Forward"],[97,"Forward"],[2,"Backward"],[96,"Backward"],[95,"Backward"],[90,"Backward"],[91,"Backward"],[89,"Backward"],[88,"Backward"],[87,"Backward"],[86,"Backward"],[85,"Backward"],[84,"Backward"],[83,"Backward"],[82,"Backward"],[81,"Backward"],[9,"Backward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":false,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null},{"boundary":[[33,"Forward"],[79,"Forward"],[9,"Forward"],[81,"Forward"],[82,"Forward"],[83,"Forward"],[84,"Forward"],[85,"Forward"],[86,"Forward"],[87,"Forward"],[88,"Forward"],[89,"Forward"],[91,"Forward"],[90,"Forward"],[95,"Forward"],[96,"Forward"],[2,"Forward"],[156,"Forward"],[159,"Forward"],[160,"Forward"],[161,"Forward"],[162,"Forward"],[163,"Forward"],[164,"Forward"],[165,"Forward"],[166,"Forward"],[167,"Forward"],[168,"Forward"],[169,"Forward"],[170,"Forward"],[171,"Forward"],[172,"Forward"],[173,"Forward"],[174,"Forward"],[176,"Forward"],[178,"Forward"],[175,"Forward"],[180,"Forward"],[182,"Forward"],[184,"Forward"],[186,"Forward"],[188,"Forward"],[12,"Forward"],[191,"Forward"],[14,"Forward"],[15,"Forward"],[16,"Forward"],[17,"Forward"],[18,"Forward"],[19,"Forward"],[20,"Forward"],[21,"Forward"],[22,"Forward"],[23,"Forward"],[24,"Forward"],[25,"Forward"],[26,"Forward"],[27,"Forward"],[28,"Forward"],[29,"Forward"],[30,"Forward"],[31,"Forward"],[8,"Forward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":true,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null},{"boundary":[[33,"Backward"],[35,"Forward"],[34,"Forward"],[37,"Forward"],[39,"Forward"],[41,"Forward"],[38,"Forward"],[43,"Forward"],[44,"Forward"],[45,"Forward"],[47,"Forward"],[46,"Forward"],[49,"Forward"],[50,"Forward"],[51,"Forward"],[53,"Forward"],[52,"Forward"],[55,"Forward"],[56,"Forward"],[57,"Forward"],[58,"Forward"],[59,"Forward"],[60,"Forward"],[61,"Forward"],[62,"Forward"],[63,"Forward"],[64,"Forward"],[66,"Forward"],[65,"Forward"],[68,"Forward"],[69,"Forward"],[70,"Forward"],[71,"Forward"],[72,"Forward"],[73,"Forward"],[74,"Forward"],[75,"Forward"],[76,"Forward"],[77,"Forward"],[78,"Forward"],[79,"Backward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":true,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null},{"boundary":[[186,"Backward"],[184,"Backward"],[182,"Backward"],[180,"Backward"],[175,"Backward"],[178,"Backward"],[176,"Backward"],[174,"Backward"],[173,"Backward"],[172,"Backward"],[171,"Backward"],[170,"Backward"],[169,"Backward"],[168,"Backward"],[167,"Backward"],[166,"Backward"],[165,"Backward"],[164,"Backward"],[163,"Backward"],[162,"Backward"],[161,"Backward"],[160,"Backward"],[159,"Backward"],[157,"Forward"],[190,"Forward"],[12,"Backward"],[188,"Backward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":false,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null},{"boundary":[[30,"Backward"],[29,"Backward"],[28,"Backward"],[27,"Backward"],[26,"Backward"],[25,"Backward"],[24,"Backward"],[23,"Backward"],[22,"Backward"],[21,"Backward"],[20,"Backward"],[19,"Backward"],[18,"Backward"],[17,"Backward"],[16,"Backward"],[15,"Backward"],[14,"Backward"],[13,"Forward"],[0,"Forward"],[4,"Forward"],[32,"Forward"],[8,"Backward"],[31,"Backward"]],"color":{"a":255,"b":255,"g":100,"r":100},"deleted":false,"fill_rule":"NonZero","gradient_fill":null,"image_fill":null}],"free_edges":[36,40,42,48,54,67,92,93,94,106,108,110,128,130,135,158,177,179,183,185,187,189,192,197,199,201,3,5,10,11,98,99,100,101,102,103,104,105,107,109,111,120,121,122,123,124,125,126,127,129,131,132,133,134,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,181,193,194,195,196,198,200,202,203,204,205,206,207,191,79,156,119,112,33],"free_fills":[4,3,1],"free_vertices":[7,8],"vertices":[{"deleted":false,"position":{"x":193.47576904296875,"y":236.10092163085938}},{"deleted":false,"position":{"x":398.01873779296875,"y":236.10092163085938}},{"deleted":false,"position":{"x":398.01873779296875,"y":366.1438903808594}},{"deleted":false,"position":{"x":193.47576904296875,"y":366.1438903808594}},{"deleted":false,"position":{"x":398.01873779296875,"y":312.4524841308594}},{"deleted":false,"position":{"x":520.5656127929688,"y":312.4524841308594}},{"deleted":false,"position":{"x":520.5656127929688,"y":402.0149841308594}},{"deleted":true,"position":{"x":398.01873779296875,"y":402.0149841308594}},{"deleted":true,"position":{"x":398.01873779296875,"y":339.1288146972656}},{"deleted":false,"position":{"x":169.92498779296875,"y":306.4056091308594}},{"deleted":false,"position":{"x":178.71405029296875,"y":303.7688903808594}},{"deleted":false,"position":{"x":190.13592529296875,"y":302.0110778808594}},{"deleted":false,"position":{"x":193.47576904296878,"y":301.5338058302084}},{"deleted":false,"position":{"x":202.43670654296875,"y":300.2532653808594}},{"deleted":false,"position":{"x":214.73748779296875,"y":299.3782653808594}},{"deleted":false,"position":{"x":227.03826904296875,"y":298.4993591308594}},{"deleted":false,"position":{"x":238.46405029296875,"y":298.4993591308594}},{"deleted":false,"position":{"x":249.88592529296875,"y":298.4993591308594}},{"deleted":false,"position":{"x":266.58123779296875,"y":299.3782653808594}},{"deleted":false,"position":{"x":278.88201904296875,"y":299.3782653808594}},{"deleted":false,"position":{"x":290.30389404296875,"y":300.2532653808594}},{"deleted":false,"position":{"x":309.63592529296875,"y":303.7688903808594}},{"deleted":false,"position":{"x":322.81561279296875,"y":308.1634216308594}},{"deleted":false,"position":{"x":333.36248779296875,"y":312.5579528808594}},{"deleted":false,"position":{"x":342.14764404296875,"y":316.0696716308594}},{"deleted":false,"position":{"x":350.93280029296875,"y":319.5852966308594}},{"deleted":false,"position":{"x":358.84295654296875,"y":321.3431091308594}},{"deleted":false,"position":{"x":367.62811279296875,"y":324.8587341308594}},{"deleted":false,"position":{"x":376.41717529296875,"y":328.3743591308594}},{"deleted":false,"position":{"x":384.32342529296875,"y":331.0071716308594}},{"deleted":false,"position":{"x":392.23358154296875,"y":331.8860778808594}},{"deleted":false,"position":{"x":398.01873779296875,"y":331.8860778808594}},{"deleted":false,"position":{"x":400.13983154296875,"y":331.8860778808594}},{"deleted":false,"position":{"x":407.17108154296875,"y":331.8860778808594}},{"deleted":false,"position":{"x":415.95623779296875,"y":331.8860778808594}},{"deleted":false,"position":{"x":422.10858154296875,"y":331.8860778808594}},{"deleted":false,"position":{"x":430.01483154296875,"y":331.8860778808594}},{"deleted":false,"position":{"x":437.92498779296875,"y":331.8860778808594}},{"deleted":false,"position":{"x":444.95233154296875,"y":331.0071716308594}},{"deleted":false,"position":{"x":449.34686279296875,"y":331.0071716308594}},{"deleted":false,"position":{"x":453.74139404296875,"y":330.1321716308594}},{"deleted":false,"position":{"x":459.88983154296875,"y":330.1321716308594}},{"deleted":false,"position":{"x":463.40545654296875,"y":330.1321716308594}},{"deleted":false,"position":{"x":467.79998779296875,"y":331.0071716308594}},{"deleted":false,"position":{"x":471.31561279296875,"y":332.7649841308594}},{"deleted":false,"position":{"x":477.46405029296875,"y":338.9173278808594}},{"deleted":false,"position":{"x":479.22186279296875,"y":342.4329528808594}},{"deleted":false,"position":{"x":480.97967529296875,"y":345.9485778808594}},{"deleted":false,"position":{"x":481.85858154296875,"y":351.2181091308594}},{"deleted":false,"position":{"x":482.73748779296875,"y":355.6126403808594}},{"deleted":false,"position":{"x":483.61639404296875,"y":359.1282653808594}},{"deleted":false,"position":{"x":483.61639404296875,"y":364.3977966308594}},{"deleted":false,"position":{"x":482.73748779296875,"y":367.9134216308594}},{"deleted":false,"position":{"x":480.10076904296875,"y":374.0657653808594}},{"deleted":false,"position":{"x":478.34295654296875,"y":376.7024841308594}},{"deleted":false,"position":{"x":476.58514404296875,"y":379.3352966308594}},{"deleted":false,"position":{"x":474.82733154296875,"y":381.9720153808594}},{"deleted":false,"position":{"x":472.19451904296875,"y":383.7298278808594}},{"deleted":false,"position":{"x":468.67889404296875,"y":385.4876403808594}},{"deleted":false,"position":{"x":465.16326904296875,"y":387.2454528808594}},{"deleted":false,"position":{"x":454.62030029296875,"y":387.2454528808594}},{"deleted":false,"position":{"x":449.34686279296875,"y":385.4876403808594}},{"deleted":false,"position":{"x":444.07342529296875,"y":382.8509216308594}},{"deleted":false,"position":{"x":438.80389404296875,"y":381.0931091308594}},{"deleted":false,"position":{"x":429.13592529296875,"y":380.2142028808594}},{"deleted":false,"position":{"x":421.22967529296875,"y":377.5774841308594}},{"deleted":false,"position":{"x":414.19842529296875,"y":374.0657653808594}},{"deleted":false,"position":{"x":407.17108154296875,"y":367.9134216308594}},{"deleted":false,"position":{"x":402.77655029296875,"y":363.5188903808594}},{"deleted":false,"position":{"x":400.13983154296875,"y":360.8860778808594}},{"deleted":false,"position":{"x":398.38201904296875,"y":358.2493591308594}},{"deleted":false,"position":{"x":395.74920654296875,"y":352.0970153808594}},{"deleted":false,"position":{"x":394.02264404296875,"y":346.9290466308594}},{"deleted":false,"position":{"x":392.85467529296875,"y":343.4134216308594}},{"deleted":false,"position":{"x":388.80389404296875,"y":341.9876403808594}},{"deleted":false,"position":{"x":383.53045654296875,"y":341.9876403808594}},{"deleted":false,"position":{"x":379.13983154296875,"y":341.1087341308594}},{"deleted":false,"position":{"x":373.86639404296875,"y":341.9876403808594}},{"deleted":false,"position":{"x":369.47186279296875,"y":344.6243591308594}},{"deleted":false,"position":{"x":365.95623779296875,"y":347.2610778808594}},{"deleted":false,"position":{"x":363.32342529296875,"y":350.7727966308594}},{"deleted":false,"position":{"x":360.68670654296875,"y":353.4095153808594}},{"deleted":false,"position":{"x":358.04998779296875,"y":356.0462341308594}},{"deleted":false,"position":{"x":355.41326904296875,"y":360.4407653808594}},{"deleted":false,"position":{"x":352.77655029296875,"y":364.8313903808594}},{"deleted":false,"position":{"x":352.77655029296875,"y":366.1438903808594}},{"deleted":false,"position":{"x":352.77655029296875,"y":371.8509216308594}},{"deleted":false,"position":{"x":355.41326904296875,"y":376.2454528808594}},{"deleted":false,"position":{"x":357.16326904296875,"y":378.8704528808594}},{"deleted":false,"position":{"x":359.79998779296875,"y":381.5032653808594}},{"deleted":false,"position":{"x":365.94842529296875,"y":382.3821716308594}},{"deleted":false,"position":{"x":370.34295654296875,"y":382.3821716308594}},{"deleted":false,"position":{"x":374.73748779296875,"y":382.3821716308594}},{"deleted":false,"position":{"x":379.12811279296875,"y":382.3821716308594}},{"deleted":false,"position":{"x":386.15936279296875,"y":382.3821716308594}},{"deleted":false,"position":{"x":391.42889404296875,"y":384.1399841308594}},{"deleted":false,"position":{"x":398.01873779296875,"y":387.06880357530383}},{"deleted":false,"position":{"x":399.33905029296875,"y":387.6556091308594}},{"deleted":false,"position":{"x":406.36639404296875,"y":391.1712341308594}},{"deleted":false,"position":{"x":415.15545654296875,"y":398.1985778808594}},{"deleted":false,"position":{"x":419.54998779296875,"y":400.8352966308594}},{"deleted":false,"position":{"x":421.02328640407984,"y":402.0149841308594}},{"deleted":false,"position":{"x":423.94061279296875,"y":404.3509216308594}},{"deleted":false,"position":{"x":427.45623779296875,"y":406.1087341308594}},{"deleted":false,"position":{"x":431.84686279296875,"y":406.1087341308594}},{"deleted":false,"position":{"x":435.35858154296875,"y":406.9837341308594}},{"deleted":false,"position":{"x":438.73358154296875,"y":408.7376403808594}},{"deleted":false,"position":{"x":441.37030029296875,"y":411.3743591308594}},{"deleted":false,"position":{"x":441.37030029296875,"y":416.6477966308594}},{"deleted":false,"position":{"x":441.37030029296875,"y":421.9212341308594}},{"deleted":false,"position":{"x":441.37030029296875,"y":425.4329528808594}},{"deleted":false,"position":{"x":438.73358154296875,"y":428.0696716308594}},{"deleted":false,"position":{"x":434.33905029296875,"y":429.8274841308594}},{"deleted":false,"position":{"x":429.06951904296875,"y":429.8274841308594}},{"deleted":false,"position":{"x":424.67498779296875,"y":429.8274841308594}},{"deleted":false,"position":{"x":420.28045654296875,"y":431.5852966308594}},{"deleted":false,"position":{"x":409.73748779296875,"y":434.2220153808594}},{"deleted":false,"position":{"x":401.82733154296875,"y":436.8587341308594}},{"deleted":false,"position":{"x":393.92108154296875,"y":437.7376403808594}},{"deleted":false,"position":{"x":383.37811279296875,"y":438.6165466308594}},{"deleted":false,"position":{"x":377.22576904296875,"y":438.6165466308594}},{"deleted":false,"position":{"x":368.44061279296875,"y":438.6165466308594}},{"deleted":false,"position":{"x":360.53045654296875,"y":436.8587341308594}},{"deleted":false,"position":{"x":352.62420654296875,"y":434.2220153808594}},{"deleted":false,"position":{"x":343.83514404296875,"y":430.7063903808594}},{"deleted":false,"position":{"x":331.53436279296875,"y":425.4329528808594}},{"deleted":false,"position":{"x":324.50311279296875,"y":421.9212341308594}},{"deleted":false,"position":{"x":317.47576904296875,"y":418.4056091308594}},{"deleted":false,"position":{"x":308.68670654296875,"y":411.3743591308594}},{"deleted":false,"position":{"x":299.90155029296875,"y":403.4681091308594}},{"deleted":false,"position":{"x":292.87030029296875,"y":396.4368591308594}},{"deleted":false,"position":{"x":283.20623779296875,"y":385.8938903808594}},{"deleted":false,"position":{"x":279.69061279296875,"y":379.7415466308594}},{"deleted":false,"position":{"x":276.17498779296875,"y":371.8352966308594}},{"deleted":false,"position":{"x":276.17498779296875,"y":367.4407653808594}},{"deleted":false,"position":{"x":276.17498779296875,"y":366.1438903808594}},{"deleted":false,"position":{"x":276.17498779296875,"y":363.9251403808594}},{"deleted":false,"position":{"x":277.05389404296875,"y":358.6556091308594}},{"deleted":false,"position":{"x":279.69061279296875,"y":353.3821716308594}},{"deleted":false,"position":{"x":282.32733154296875,"y":351.6243591308594}},{"deleted":false,"position":{"x":285.84295654296875,"y":348.1087341308594}},{"deleted":false,"position":{"x":286.72186279296875,"y":344.0149841308594}},{"deleted":false,"position":{"x":284.96405029296875,"y":341.3782653808594}},{"deleted":false,"position":{"x":281.44842529296875,"y":338.7415466308594}},{"deleted":false,"position":{"x":276.17498779296875,"y":336.9837341308594}},{"deleted":false,"position":{"x":267.38983154296875,"y":335.2259216308594}},{"deleted":false,"position":{"x":263.87420654296875,"y":334.3470153808594}},{"deleted":false,"position":{"x":260.35858154296875,"y":332.5892028808594}},{"deleted":false,"position":{"x":256.84686279296875,"y":331.7102966308594}},{"deleted":false,"position":{"x":248.93670654296875,"y":329.0774841308594}},{"deleted":false,"position":{"x":243.66326904296875,"y":328.1985778808594}},{"deleted":false,"position":{"x":234.87811279296875,"y":326.4407653808594}},{"deleted":false,"position":{"x":231.36248779296875,"y":326.4407653808594}},{"deleted":false,"position":{"x":226.96795654296875,"y":326.4407653808594}},{"deleted":false,"position":{"x":221.69842529296875,"y":326.4407653808594}},{"deleted":false,"position":{"x":213.78826904296875,"y":327.3196716308594}},{"deleted":false,"position":{"x":208.51873779296875,"y":327.3196716308594}},{"deleted":false,"position":{"x":205.00311279296875,"y":327.3196716308594}},{"deleted":false,"position":{"x":200.82733154296875,"y":327.3196716308594}},{"deleted":false,"position":{"x":197.34686279296875,"y":327.3196716308594}},{"deleted":false,"position":{"x":194.06561279296875,"y":327.3196716308594}},{"deleted":false,"position":{"x":190.54998779296875,"y":327.3196716308594}},{"deleted":false,"position":{"x":185.28045654296875,"y":328.1985778808594}},{"deleted":false,"position":{"x":176.49139404296875,"y":329.0774841308594}},{"deleted":false,"position":{"x":171.22186279296875,"y":329.0774841308594}},{"deleted":false,"position":{"x":165.94842529296875,"y":329.0774841308594}},{"deleted":false,"position":{"x":158.91717529296875,"y":329.0774841308594}},{"deleted":false,"position":{"x":155.40545654296875,"y":329.0774841308594}},{"deleted":false,"position":{"x":151.88983154296875,"y":328.1985778808594}},{"deleted":false,"position":{"x":148.44061279296875,"y":327.3196716308594}},{"deleted":false,"position":{"x":144.04998779296875,"y":325.5618591308594}},{"deleted":false,"position":{"x":140.71405029296875,"y":323.8626403808594}},{"deleted":false,"position":{"x":138.07733154296875,"y":321.2298278808594}}]},"segments":[[[452.07342529296875,271.6595153808594],[450.60988362630206,272.8313903808594],[449.14634195963544,274.0032653808594],[447.68280029296875,275.1751403808594]],[[447.68280029296875,275.1751403808594],[447.68280029296875,276.63868204752606],[447.68280029296875,278.1022237141927],[447.68280029296875,279.5657653808594]],[[447.68280029296875,279.5657653808594],[447.68280029296875,281.0306091308594],[447.68280029296875,282.4954528808594],[447.68280029296875,283.9602966308594]],[[447.68280029296875,283.9602966308594],[447.38983154296875,285.4251403808594],[447.09686279296875,286.8899841308594],[446.80389404296875,288.3548278808594]],[[446.80389404296875,288.3548278808594],[446.51092529296875,290.69727579752606],[446.21795654296875,293.0397237141927],[445.92498779296875,295.3821716308594]],[[445.92498779296875,295.3821716308594],[445.63201904296875,298.3118591308594],[445.33905029296875,301.2415466308594],[445.04608154296875,304.1712341308594]],[[445.04608154296875,304.1712341308594],[444.75311279296875,305.3431091308594],[444.46014404296875,306.5149841308594],[444.16717529296875,307.6868591308594]],[[444.16717529296875,307.6868591308594],[444.16717529296875,309.44336954752606],[444.16717529296875,311.1998799641927],[444.16717529296875,312.9563903808594]],[[444.16717529296875,312.9563903808594],[444.16717529296875,315.5931091308594],[444.16717529296875,318.2298278808594],[444.16717529296875,320.8665466308594]],[[444.16717529296875,320.8665466308594],[444.16717529296875,324.67383829752606],[444.16717529296875,328.4811299641927],[444.16717529296875,332.2884216308594]],[[444.16717529296875,332.2884216308594],[444.16717529296875,336.38868204752606],[444.16717529296875,340.4889424641927],[444.16717529296875,344.5892028808594]],[[444.16717529296875,344.5892028808594],[444.16717529296875,347.8118591308594],[444.16717529296875,351.0345153808594],[444.16717529296875,354.2571716308594]],[[444.16717529296875,354.2571716308594],[444.16717529296875,356.30665079752606],[444.16717529296875,358.3561299641927],[444.16717529296875,360.4056091308594]],[[444.16717529296875,360.4056091308594],[444.16717529296875,362.1634216308594],[444.16717529296875,363.9212341308594],[444.16717529296875,365.6790466308594]],[[444.16717529296875,365.6790466308594],[444.16717529296875,366.8509216308594],[444.16717529296875,368.0227966308594],[444.16717529296875,369.1946716308594]],[[444.16717529296875,369.1946716308594],[445.33905029296875,370.65821329752606],[446.51092529296875,372.1217549641927],[447.68280029296875,373.5852966308594]],[[447.68280029296875,373.5852966308594],[448.56170654296875,375.0501403808594],[449.44061279296875,376.5149841308594],[450.31951904296875,377.9798278808594]],[[450.31951904296875,377.9798278808594],[451.49009195963544,379.4446716308594],[452.66066487630206,380.9095153808594],[453.83123779296875,382.3743591308594]],[[453.83123779296875,382.3743591308594],[455.00311279296875,383.8392028808594],[456.17498779296875,385.3040466308594],[457.34686279296875,386.7688903808594]],[[457.34686279296875,386.7688903808594],[458.22576904296875,387.64649454752606],[459.10467529296875,388.5240987141927],[459.98358154296875,389.4017028808594]],[[459.98358154296875,389.4017028808594],[461.15545654296875,390.5735778808594],[462.32733154296875,391.7454528808594],[463.49920654296875,392.9173278808594]],[[463.49920654296875,392.9173278808594],[464.66977945963544,394.0892028808594],[465.84035237630206,395.2610778808594],[467.01092529296875,396.4329528808594]],[[467.01092529296875,396.4329528808594],[468.47576904296875,398.1907653808594],[469.94061279296875,399.9485778808594],[471.40545654296875,401.7063903808594]],[[471.40545654296875,401.7063903808594],[472.57733154296875,402.87696329752606],[473.74920654296875,404.0475362141927],[474.92108154296875,405.2181091308594]],[[474.92108154296875,405.2181091308594],[476.09295654296875,407.2688903808594],[477.26483154296875,409.3196716308594],[478.43670654296875,411.3704528808594]],[[478.43670654296875,411.3704528808594],[479.31561279296875,414.00586954752606],[480.19451904296875,416.6412862141927],[481.07342529296875,419.2767028808594]],[[481.07342529296875,419.2767028808594],[481.36639404296875,420.4485778808594],[481.65936279296875,421.6204528808594],[481.95233154296875,422.7923278808594]],[[481.95233154296875,422.7923278808594],[481.95233154296875,424.5501403808594],[481.95233154296875,426.3079528808594],[481.95233154296875,428.0657653808594]],[[481.95233154296875,428.0657653808594],[482.24399820963544,429.5306091308594],[482.53566487630206,430.9954528808594],[482.82733154296875,432.4602966308594]],[[482.82733154296875,432.4602966308594],[483.12030029296875,433.92383829752606],[483.41326904296875,435.3873799641927],[483.70623779296875,436.8509216308594]],[[483.70623779296875,436.8509216308594],[483.70623779296875,438.3157653808594],[483.70623779296875,439.7806091308594],[483.70623779296875,441.2454528808594]],[[483.70623779296875,441.2454528808594],[483.70623779296875,442.7102966308594],[483.70623779296875,444.1751403808594],[483.70623779296875,445.6399841308594]],[[483.70623779296875,445.6399841308594],[482.82863362630206,447.39649454752606],[481.95102945963544,449.1530049641927],[481.07342529296875,450.9095153808594]],[[481.07342529296875,450.9095153808594],[480.48748779296875,451.7884216308594],[479.90155029296875,452.6673278808594],[479.31561279296875,453.5462341308594]],[[479.31561279296875,453.5462341308594],[478.14373779296875,454.4251403808594],[476.97186279296875,455.3040466308594],[475.79998779296875,456.1829528808594]],[[475.79998779296875,456.1829528808594],[474.33514404296875,456.4759216308594],[472.87030029296875,456.7688903808594],[471.40545654296875,457.0618591308594]],[[471.40545654296875,457.0618591308594],[469.94061279296875,457.0618591308594],[468.47576904296875,457.0618591308594],[467.01092529296875,457.0618591308594]],[[467.01092529296875,457.0618591308594],[465.25441487630206,457.0618591308594],[463.49790445963544,457.0618591308594],[461.74139404296875,457.0618591308594]],[[461.74139404296875,457.0618591308594],[460.56951904296875,456.4759216308594],[459.39764404296875,455.8899841308594],[458.22576904296875,455.3040466308594]],[[458.22576904296875,455.3040466308594],[456.76092529296875,454.4251403808594],[455.29608154296875,453.5462341308594],[453.83123779296875,452.6673278808594]],[[453.83123779296875,452.6673278808594],[452.95363362630206,452.0813903808594],[452.07602945963544,451.4954528808594],[451.19842529296875,450.9095153808594]],[[451.19842529296875,450.9095153808594],[450.07342529296875,450.6178487141927],[448.94842529296875,450.32618204752606],[447.82342529296875,450.0345153808594]],[[447.82342529296875,450.0345153808594],[446.09946695963544,449.45899454752606],[444.37550862630206,448.8834737141927],[442.65155029296875,448.3079528808594]],[[442.65155029296875,448.3079528808594],[440.60076904296875,448.3079528808594],[438.54998779296875,448.3079528808594],[436.49920654296875,448.3079528808594]],[[436.49920654296875,448.3079528808594],[434.74269612630206,448.0162862141927],[432.98618570963544,447.72461954752606],[431.22967529296875,447.4329528808594]],[[431.22967529296875,447.4329528808594],[429.76483154296875,446.8470153808594],[428.29998779296875,446.2610778808594],[426.83514404296875,445.6751403808594]],[[426.83514404296875,445.6751403808594],[424.78436279296875,445.0892028808594],[422.73358154296875,444.5032653808594],[420.68280029296875,443.9173278808594]],[[420.68280029296875,443.9173278808594],[419.51092529296875,443.3313903808594],[418.33905029296875,442.7454528808594],[417.16717529296875,442.1595153808594]],[[417.16717529296875,442.1595153808594],[416.28957112630206,441.5735778808594],[415.41196695963544,440.9876403808594],[414.53436279296875,440.4017028808594]],[[414.53436279296875,440.4017028808594],[412.77655029296875,438.6438903808594],[411.01873779296875,436.8860778808594],[409.26092529296875,435.1282653808594]],[[409.26092529296875,435.1282653808594],[408.08905029296875,433.9576924641927],[406.91717529296875,432.78711954752606],[405.74530029296875,431.6165466308594]],[[405.74530029296875,431.6165466308594],[404.86639404296875,430.7376403808594],[403.98748779296875,429.8587341308594],[403.10858154296875,428.9798278808594]],[[403.10858154296875,428.9798278808594],[401.35207112630206,427.2220153808594],[399.59556070963544,425.4642028808594],[397.83905029296875,423.7063903808594]],[[397.83905029296875,423.7063903808594],[396.96014404296875,422.8274841308594],[396.08123779296875,421.9485778808594],[395.20233154296875,421.0696716308594]],[[395.20233154296875,421.0696716308594],[394.32342529296875,419.8977966308594],[393.44451904296875,418.7259216308594],[392.56561279296875,417.5540466308594]],[[392.56561279296875,417.5540466308594],[391.68670654296875,416.6764424641927],[390.80780029296875,415.79883829752606],[389.92889404296875,414.9212341308594]],[[389.92889404296875,414.9212341308594],[389.34295654296875,414.0423278808594],[388.75701904296875,413.1634216308594],[388.17108154296875,412.2845153808594]],[[388.17108154296875,412.2845153808594],[387.58514404296875,411.1126403808594],[386.99920654296875,409.9407653808594],[386.41326904296875,408.7688903808594]],[[386.41326904296875,408.7688903808594],[385.82863362630206,407.8899841308594],[385.24399820963544,407.0110778808594],[384.65936279296875,406.1321716308594]],[[384.65936279296875,406.1321716308594],[384.07342529296875,404.9602966308594],[383.48748779296875,403.7884216308594],[382.90155029296875,402.6165466308594]],[[382.90155029296875,402.6165466308594],[382.31561279296875,401.7389424641927],[381.72967529296875,400.86133829752606],[381.14373779296875,399.9837341308594]],[[381.14373779296875,399.9837341308594],[380.55780029296875,399.1048278808594],[379.97186279296875,398.2259216308594],[379.38592529296875,397.3470153808594]],[[379.38592529296875,397.3470153808594],[378.79998779296875,396.1751403808594],[378.21405029296875,395.0032653808594],[377.62811279296875,393.8313903808594]],[[377.62811279296875,393.8313903808594],[377.04217529296875,392.6595153808594],[376.45623779296875,391.4876403808594],[375.87030029296875,390.3157653808594]],[[375.87030029296875,390.3157653808594],[374.99139404296875,388.8522237141927],[374.11248779296875,387.38868204752606],[373.23358154296875,385.9251403808594]],[[373.23358154296875,385.9251403808594],[371.77003987630206,383.8743591308594],[370.30649820963544,381.8235778808594],[368.84295654296875,379.7727966308594]],[[368.84295654296875,379.7727966308594],[367.96405029296875,378.3079528808594],[367.08514404296875,376.8431091308594],[366.20623779296875,375.3782653808594]],[[366.20623779296875,375.3782653808594],[365.32733154296875,373.6217549641927],[364.44842529296875,371.86524454752606],[363.56951904296875,370.1087341308594]],[[363.56951904296875,370.1087341308594],[362.98358154296875,368.6438903808594],[362.39764404296875,367.1790466308594],[361.81170654296875,365.7142028808594]],[[361.81170654296875,365.7142028808594],[361.22576904296875,364.5423278808594],[360.63983154296875,363.3704528808594],[360.05389404296875,362.1985778808594]],[[360.05389404296875,362.1985778808594],[359.46795654296875,361.0267028808594],[358.88201904296875,359.8548278808594],[358.29608154296875,358.6829528808594]],[[358.29608154296875,358.6829528808594],[358.00441487630206,357.5123799641927],[357.71274820963544,356.34180704752606],[357.42108154296875,355.1712341308594]],[[357.42108154296875,355.1712341308594],[357.12811279296875,353.70899454752606],[356.83514404296875,352.2467549641927],[356.54217529296875,350.7845153808594]],[[356.54217529296875,350.7845153808594],[356.54217529296875,349.6126403808594],[356.54217529296875,348.4407653808594],[356.54217529296875,347.2688903808594]],[[356.54217529296875,347.2688903808594],[355.95623779296875,345.5123799641927],[355.37030029296875,343.75586954752606],[354.78436279296875,341.9993591308594]],[[354.78436279296875,341.9993591308594],[354.19842529296875,339.9485778808594],[353.61248779296875,337.8977966308594],[353.02655029296875,335.8470153808594]],[[353.02655029296875,335.8470153808594],[352.14764404296875,333.2115987141927],[351.26873779296875,330.57618204752606],[350.38983154296875,327.9407653808594]],[[350.38983154296875,327.9407653808594],[350.09816487630206,325.3040466308594],[349.80649820963544,322.6673278808594],[349.51483154296875,320.0306091308594]],[[349.51483154296875,320.0306091308594],[349.51483154296875,317.6868591308594],[349.51483154296875,315.3431091308594],[349.51483154296875,312.9993591308594]],[[349.51483154296875,312.9993591308594],[349.51483154296875,311.2428487141927],[349.51483154296875,309.48633829752606],[349.51483154296875,307.7298278808594]],[[349.51483154296875,307.7298278808594],[349.51483154296875,306.2649841308594],[349.51483154296875,304.8001403808594],[349.51483154296875,303.3352966308594]],[[349.51483154296875,303.3352966308594],[349.51483154296875,301.58008829752606],[349.51483154296875,299.8248799641927],[349.51483154296875,298.0696716308594]],[[349.51483154296875,298.0696716308594],[348.92889404296875,295.7259216308594],[348.34295654296875,293.3821716308594],[347.75701904296875,291.0384216308594]],[[347.75701904296875,291.0384216308594],[347.17108154296875,289.2819112141927],[346.58514404296875,287.52540079752606],[345.99920654296875,285.7688903808594]],[[345.99920654296875,285.7688903808594],[345.41326904296875,284.8899841308594],[344.82733154296875,284.0110778808594],[344.24139404296875,283.1321716308594]],[[344.24139404296875,283.1321716308594],[343.36248779296875,281.3743591308594],[342.48358154296875,279.6165466308594],[341.60467529296875,277.8587341308594]],[[341.60467529296875,277.8587341308594],[340.43280029296875,276.1022237141927],[339.26092529296875,274.34571329752606],[338.08905029296875,272.5892028808594]],[[338.08905029296875,272.5892028808594],[337.21014404296875,271.7102966308594],[336.33123779296875,270.8313903808594],[335.45233154296875,269.9524841308594]],[[335.45233154296875,269.9524841308594],[334.57472737630206,269.3665466308594],[333.69712320963544,268.7806091308594],[332.81951904296875,268.1946716308594]],[[332.81951904296875,268.1946716308594],[331.06170654296875,267.9017028808594],[329.30389404296875,267.6087341308594],[327.54608154296875,267.3157653808594]],[[327.54608154296875,267.3157653808594],[325.78826904296875,267.0227966308594],[324.03045654296875,266.7298278808594],[322.27264404296875,266.4368591308594]],[[322.27264404296875,266.4368591308594],[320.51613362630206,266.4368591308594],[318.75962320963544,266.4368591308594],[317.00311279296875,266.4368591308594]],[[317.00311279296875,266.4368591308594],[315.53826904296875,265.8509216308594],[314.07342529296875,265.2649841308594],[312.60858154296875,264.6790466308594]],[[312.60858154296875,264.6790466308594],[310.85076904296875,264.3860778808594],[309.09295654296875,264.0931091308594],[307.33514404296875,263.8001403808594]],[[307.33514404296875,263.8001403808594],[305.57863362630206,263.5071716308594],[303.82212320963544,263.2142028808594],[302.06561279296875,262.9212341308594]],[[302.06561279296875,262.9212341308594],[300.89373779296875,262.9212341308594],[299.72186279296875,262.9212341308594],[298.54998779296875,262.9212341308594]],[[298.54998779296875,262.9212341308594],[297.08514404296875,262.9212341308594],[295.62030029296875,262.9212341308594],[294.15545654296875,262.9212341308594]],[[294.15545654296875,262.9212341308594],[292.98358154296875,262.9212341308594],[291.81170654296875,262.9212341308594],[290.63983154296875,262.9212341308594]],[[290.63983154296875,262.9212341308594],[289.46925862630206,262.9212341308594],[288.29868570963544,262.9212341308594],[287.12811279296875,262.9212341308594]],[[287.12811279296875,262.9212341308594],[285.66326904296875,263.2142028808594],[284.19842529296875,263.5071716308594],[282.73358154296875,263.8001403808594]],[[282.73358154296875,263.8001403808594],[280.97576904296875,264.3860778808594],[279.21795654296875,264.9720153808594],[277.46014404296875,265.5579528808594]],[[277.46014404296875,265.5579528808594],[275.41066487630206,266.1438903808594],[273.36118570963544,266.7298278808594],[271.31170654296875,267.3157653808594]],[[271.31170654296875,267.3157653808594],[269.55389404296875,267.6087341308594],[267.79608154296875,267.9017028808594],[266.03826904296875,268.1946716308594]],[[266.03826904296875,268.1946716308594],[263.98748779296875,268.4876403808594],[261.93670654296875,268.7806091308594],[259.88592529296875,269.0735778808594]],[[259.88592529296875,269.0735778808594],[258.42238362630206,269.0735778808594],[256.95884195963544,269.0735778808594],[255.49530029296875,269.0735778808594]],[[255.49530029296875,269.0735778808594],[254.03045654296875,269.0735778808594],[252.56561279296875,269.0735778808594],[251.10076904296875,269.0735778808594]],[[251.10076904296875,269.0735778808594],[249.7518107096354,269.0735778808594],[248.4028523763021,269.0735778808594],[247.05389404296875,269.0735778808594]],[[247.05389404296875,269.0735778808594],[245.98358154296875,269.3470153808594],[244.91326904296875,269.6204528808594],[243.84295654296875,269.8938903808594]],[[243.84295654296875,269.8938903808594],[242.3807169596354,270.1868591308594],[240.9184773763021,270.4798278808594],[239.45623779296875,270.7727966308594]],[[239.45623779296875,270.7727966308594],[237.9939982096354,271.06446329752606],[236.5317586263021,271.3561299641927],[235.06951904296875,271.6477966308594]],[[235.06951904296875,271.6477966308594],[233.6059773763021,272.2337341308594],[232.1424357096354,272.8196716308594],[230.67889404296875,273.4056091308594]],[[230.67889404296875,273.4056091308594],[229.21405029296875,274.5774841308594],[227.74920654296875,275.7493591308594],[226.28436279296875,276.9212341308594]],[[226.28436279296875,276.9212341308594],[225.40545654296875,277.79883829752606],[224.52655029296875,278.6764424641927],[223.64764404296875,279.5540466308594]],[[223.64764404296875,279.5540466308594],[223.35467529296875,281.8977966308594],[223.06170654296875,284.2415466308594],[222.76873779296875,286.5852966308594]],[[222.76873779296875,286.5852966308594],[222.76873779296875,287.7571716308594],[222.76873779296875,288.9290466308594],[222.76873779296875,290.1009216308594]],[[222.76873779296875,290.1009216308594],[222.76873779296875,291.5657653808594],[222.76873779296875,293.0306091308594],[222.76873779296875,294.4954528808594]],[[222.76873779296875,294.4954528808594],[222.76873779296875,295.66602579752606],[222.76873779296875,296.8365987141927],[222.76873779296875,298.0071716308594]],[[222.76873779296875,298.0071716308594],[222.76873779296875,299.17774454752606],[222.76873779296875,300.3483174641927],[222.76873779296875,301.5188903808594]],[[222.76873779296875,301.5188903808594],[223.06170654296875,302.9837341308594],[223.35467529296875,304.4485778808594],[223.64764404296875,305.9134216308594]],[[223.64764404296875,305.9134216308594],[223.94061279296875,307.0045674641927],[224.23358154296875,308.09571329752606],[224.52655029296875,309.1868591308594]],[[224.52655029296875,309.1868591308594],[225.09295654296875,310.04493204752606],[225.65936279296875,310.9030049641927],[226.22576904296875,311.7610778808594]],[[226.22576904296875,311.7610778808594],[226.51873779296875,312.85743204752606],[226.81170654296875,313.9537862141927],[227.10467529296875,315.0501403808594]],[[227.10467529296875,315.0501403808594],[227.67889404296875,316.1998799641927],[228.25311279296875,317.34961954752606],[228.82733154296875,318.4993591308594]],[[228.82733154296875,318.4993591308594],[229.12030029296875,319.6686299641927],[229.41326904296875,320.83790079752606],[229.70623779296875,322.0071716308594]],[[229.70623779296875,322.0071716308594],[230.2908732096354,322.88477579752606],[230.8755086263021,323.7623799641927],[231.46014404296875,324.6399841308594]],[[231.46014404296875,324.6399841308594],[231.75311279296875,326.10352579752606],[232.04608154296875,327.5670674641927],[232.33905029296875,329.0306091308594]],[[232.33905029296875,329.0306091308594],[232.92498779296875,330.2024841308594],[233.51092529296875,331.3743591308594],[234.09686279296875,332.5462341308594]],[[234.09686279296875,332.5462341308594],[234.68280029296875,334.3040466308594],[235.26873779296875,336.0618591308594],[235.85467529296875,337.8196716308594]],[[235.85467529296875,337.8196716308594],[236.73358154296875,339.2845153808594],[237.61248779296875,340.7493591308594],[238.49139404296875,342.2142028808594]],[[238.49139404296875,342.2142028808594],[239.07733154296875,343.97071329752606],[239.66326904296875,345.7272237141927],[240.24920654296875,347.4837341308594]],[[240.24920654296875,347.4837341308594],[240.54217529296875,348.6556091308594],[240.83514404296875,349.8274841308594],[241.12811279296875,350.9993591308594]],[[241.12811279296875,350.9993591308594],[241.71405029296875,353.0501403808594],[242.29998779296875,355.1009216308594],[242.88592529296875,357.1517028808594]],[[242.88592529296875,357.1517028808594],[242.88592529296875,358.32227579752606],[242.88592529296875,359.4928487141927],[242.88592529296875,360.6634216308594]],[[242.88592529296875,360.6634216308594],[243.17889404296875,362.1282653808594],[243.47186279296875,363.5931091308594],[243.76483154296875,365.0579528808594]],[[243.76483154296875,365.0579528808594],[243.76483154296875,366.2298278808594],[243.76483154296875,367.4017028808594],[243.76483154296875,368.5735778808594]],[[243.76483154296875,368.5735778808594],[244.05780029296875,369.7454528808594],[244.35076904296875,370.9173278808594],[244.64373779296875,372.0892028808594]],[[244.64373779296875,372.0892028808594],[244.64373779296875,374.13868204752606],[244.64373779296875,376.1881612141927],[244.64373779296875,378.2376403808594]],[[244.64373779296875,378.2376403808594],[244.64373779296875,379.4095153808594],[244.64373779296875,380.5813903808594],[244.64373779296875,381.7532653808594]],[[244.64373779296875,381.7532653808594],[244.64373779296875,383.5110778808594],[244.64373779296875,385.2688903808594],[244.64373779296875,387.0267028808594]],[[244.64373779296875,387.0267028808594],[244.93670654296875,388.49024454752606],[245.22967529296875,389.9537862141927],[245.52264404296875,391.4173278808594]],[[245.52264404296875,391.4173278808594],[245.52264404296875,392.8821716308594],[245.52264404296875,394.3470153808594],[245.52264404296875,395.8118591308594]],[[245.52264404296875,395.8118591308594],[245.52264404296875,397.5696716308594],[245.52264404296875,399.3274841308594],[245.52264404296875,401.0852966308594]],[[245.52264404296875,401.0852966308594],[245.52264404296875,403.13477579752606],[245.52264404296875,405.1842549641927],[245.52264404296875,407.2337341308594]],[[245.52264404296875,407.2337341308594],[245.52264404296875,408.6985778808594],[245.52264404296875,410.1634216308594],[245.52264404296875,411.6282653808594]],[[245.52264404296875,411.6282653808594],[245.81561279296875,412.8001403808594],[246.10858154296875,413.9720153808594],[246.40155029296875,415.1438903808594]],[[246.40155029296875,415.1438903808594],[246.40155029296875,416.3157653808594],[246.40155029296875,417.4876403808594],[246.40155029296875,418.6595153808594]],[[246.40155029296875,418.6595153808594],[246.9861857096354,420.41602579752606],[247.5708211263021,422.1725362141927],[248.15545654296875,423.9290466308594]],[[248.15545654296875,423.9290466308594],[248.74139404296875,424.8079528808594],[249.32733154296875,425.6868591308594],[249.91326904296875,426.5657653808594]],[[249.91326904296875,426.5657653808594],[251.37811279296875,428.3235778808594],[252.84295654296875,430.0813903808594],[254.30780029296875,431.8392028808594]],[[254.30780029296875,431.8392028808594],[256.06561279296875,433.59571329752606],[257.82342529296875,435.3522237141927],[259.58123779296875,437.1087341308594]],[[259.58123779296875,437.1087341308594],[263.09556070963544,439.4524841308594],[266.60988362630206,441.7962341308594],[270.12420654296875,444.1399841308594]],[[270.12420654296875,444.1399841308594],[272.46795654296875,445.0188903808594],[274.81170654296875,445.8977966308594],[277.15545654296875,446.7767028808594]],[[277.15545654296875,446.7767028808594],[279.20493570963544,447.6556091308594],[281.25441487630206,448.5345153808594],[283.30389404296875,449.4134216308594]],[[283.30389404296875,449.4134216308594],[285.64764404296875,449.9993591308594],[287.99139404296875,450.5852966308594],[290.33514404296875,451.1712341308594]],[[290.33514404296875,451.1712341308594],[293.84946695963544,452.34180704752606],[297.36378987630206,453.5123799641927],[300.87811279296875,454.6829528808594]],[[300.87811279296875,454.6829528808594],[303.80649820963544,455.5618591308594],[306.73488362630206,456.4407653808594],[309.66326904296875,457.3196716308594]],[[309.66326904296875,457.3196716308594],[314.05780029296875,459.0774841308594],[318.45233154296875,460.8352966308594],[322.84686279296875,462.5931091308594]],[[322.84686279296875,462.5931091308594],[326.06821695963544,463.7649841308594],[329.28957112630206,464.9368591308594],[332.51092529296875,466.1087341308594]],[[332.51092529296875,466.1087341308594],[335.43931070963544,467.27930704752606],[338.36769612630206,468.4498799641927],[341.29608154296875,469.6204528808594]],[[341.29608154296875,469.6204528808594],[345.10467529296875,471.0852966308594],[348.91326904296875,472.5501403808594],[352.72186279296875,474.0149841308594]],[[352.72186279296875,474.0149841308594],[354.77134195963544,475.1868591308594],[356.82082112630206,476.3587341308594],[358.87030029296875,477.5306091308594]],[[358.87030029296875,477.5306091308594],[361.79998779296875,478.7024841308594],[364.72967529296875,479.8743591308594],[367.65936279296875,481.0462341308594]],[[367.65936279296875,481.0462341308594],[371.46665445963544,482.50977579752606],[375.27394612630206,483.9733174641927],[379.08123779296875,485.4368591308594]],[[379.08123779296875,485.4368591308594],[381.42368570963544,486.0227966308594],[383.76613362630206,486.6087341308594],[386.10858154296875,487.1946716308594]],[[386.10858154296875,487.1946716308594],[388.45233154296875,487.7806091308594],[390.79608154296875,488.3665466308594],[393.13983154296875,488.9524841308594]],[[393.13983154296875,488.9524841308594],[396.36118570963544,489.5384216308594],[399.58253987630206,490.1243591308594],[402.80389404296875,490.7102966308594]],[[402.80389404296875,490.7102966308594],[405.14764404296875,491.0032653808594],[407.49139404296875,491.2962341308594],[409.83514404296875,491.5892028808594]],[[409.83514404296875,491.5892028808594],[413.64243570963544,491.8821716308594],[417.44972737630206,492.1751403808594],[421.25701904296875,492.4681091308594]],[[421.25701904296875,492.4681091308594],[423.01483154296875,492.4681091308594],[424.77264404296875,492.4681091308594],[426.53045654296875,492.4681091308594]],[[426.53045654296875,492.4681091308594],[428.28826904296875,492.4681091308594],[430.04608154296875,492.4681091308594],[431.80389404296875,492.4681091308594]],[[431.80389404296875,492.4681091308594],[434.14634195963544,492.4681091308594],[436.48878987630206,492.4681091308594],[438.83123779296875,492.4681091308594]],[[438.83123779296875,492.4681091308594],[440.88201904296875,492.4681091308594],[442.93280029296875,492.4681091308594],[444.98358154296875,492.4681091308594]],[[444.98358154296875,492.4681091308594],[447.61899820963544,492.4681091308594],[450.25441487630206,492.4681091308594],[452.88983154296875,492.4681091308594]],[[452.88983154296875,492.4681091308594],[454.06170654296875,492.4681091308594],[455.23358154296875,492.4681091308594],[456.40545654296875,492.4681091308594]],[[456.40545654296875,492.4681091308594],[459.04087320963544,492.1751403808594],[461.67628987630206,491.8821716308594],[464.31170654296875,491.5892028808594]],[[464.31170654296875,491.5892028808594],[466.06951904296875,491.0032653808594],[467.82733154296875,490.4173278808594],[469.58514404296875,489.8313903808594]],[[469.58514404296875,489.8313903808594],[471.63592529296875,489.5384216308594],[473.68670654296875,489.2454528808594],[475.73748779296875,488.9524841308594]],[[475.73748779296875,488.9524841308594],[476.90806070963544,488.3665466308594],[478.07863362630206,487.7806091308594],[479.24920654296875,487.1946716308594]],[[479.24920654296875,487.1946716308594],[480.41977945963544,485.73243204752606],[481.59035237630206,484.2701924641927],[482.76092529296875,482.8079528808594]],[[482.76092529296875,482.8079528808594],[483.93149820963544,481.3444112141927],[485.10207112630206,479.88086954752606],[486.27264404296875,478.4173278808594]],[[486.27264404296875,478.4173278808594],[487.44451904296875,477.5384216308594],[488.61639404296875,476.6595153808594],[489.78826904296875,475.7806091308594]],[[489.78826904296875,475.7806091308594],[491.25181070963544,475.1946716308594],[492.71535237630206,474.6087341308594],[494.17889404296875,474.0227966308594]],[[494.17889404296875,474.0227966308594],[495.63852945963544,474.0227966308594],[497.09816487630206,474.0227966308594],[498.55780029296875,474.0227966308594]],[[498.55780029296875,474.0227966308594],[499.43670654296875,473.4368591308594],[500.31561279296875,472.8509216308594],[501.19451904296875,472.2649841308594]],[[501.19451904296875,472.2649841308594],[502.36118570963544,471.97461954752606],[503.52785237630206,471.6842549641927],[504.69451904296875,471.3938903808594]],[[504.69451904296875,471.3938903808594],[505.57082112630206,470.8092549641927],[506.44712320963544,470.22461954752606],[507.32342529296875,469.6399841308594]],[[507.32342529296875,469.6399841308594],[509.03436279296875,469.0540466308594],[510.74530029296875,468.4681091308594],[512.4562377929688,467.8821716308594]],[[512.4562377929688,467.8821716308594],[513.3768107096354,467.4264424641927],[514.2973836263021,466.97071329752606],[515.2179565429688,466.5149841308594]],[[515.2179565429688,466.5149841308594],[494.16977945963544,401.5631612141927],[473.12160237630206,336.611338297526],[452.07342529296875,271.6595153808594]]]} \ No newline at end of file diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/tween.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/tween.rs new file mode 100644 index 0000000..f19c59f --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/tween.rs @@ -0,0 +1,78 @@ +//! Tests for same-topology shape-tween interpolation (`VectorGraph::interpolated`). + +use super::super::*; +use kurbo::{CubicBez, Point}; + +fn line(a: Point, b: Point) -> CubicBez { + CubicBez::new( + a, + Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0), + Point::new(a.x + 2.0 * (b.x - a.x) / 3.0, a.y + 2.0 * (b.y - a.y) / 3.0), + b, + ) +} + +/// Triangle (3 verts, 3 edges, 1 fill) offset by (ox, oy). +fn triangle(ox: f64, oy: f64) -> VectorGraph { + let mut g = VectorGraph::new(); + let p = [ + Point::new(ox, oy), + Point::new(ox + 100.0, oy), + Point::new(ox + 50.0, oy + 100.0), + ]; + let v: Vec<_> = p.iter().map(|&pt| g.alloc_vertex(pt)).collect(); + let style = StrokeStyle { width: 1.0, ..Default::default() }; + let mut boundary = Vec::new(); + for i in 0..3 { + let e = g.alloc_edge( + line(p[i], p[(i + 1) % 3]), + v[i], + v[(i + 1) % 3], + Some(style.clone()), + Some(ShapeColor::rgb(0, 0, 0)), + ); + boundary.push((e, Direction::Forward)); + } + g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero); + g +} + +#[test] +fn interpolate_same_topology_lerps_positions() { + let a = triangle(0.0, 0.0); + let b = triangle(100.0, 50.0); + + let mid = a.interpolated(&b, 0.5).expect("same topology should interpolate"); + // Vertex 0: (0,0) and (100,50) → (50,25). Curve endpoints follow. + assert!((mid.vertices[0].position.x - 50.0).abs() < 1e-6); + assert!((mid.vertices[0].position.y - 25.0).abs() < 1e-6); + assert!((mid.edges[0].curve.p0.x - 50.0).abs() < 1e-6); + + // Endpoints: t=0 is `a`, t=1 is `b`. + assert!((a.interpolated(&b, 0.0).unwrap().vertices[0].position.x - 0.0).abs() < 1e-6); + assert!((a.interpolated(&b, 1.0).unwrap().vertices[0].position.x - 100.0).abs() < 1e-6); +} + +#[test] +fn interpolate_lerps_fill_color() { + let mut a = triangle(0.0, 0.0); + let mut b = triangle(0.0, 0.0); + a.fills[0].color = Some(ShapeColor::rgb(0, 0, 0)); + b.fills[0].color = Some(ShapeColor::rgb(100, 200, 40)); + let mid = a.interpolated(&b, 0.5).unwrap(); + let c = mid.fills[0].color.unwrap(); + assert_eq!((c.r, c.g, c.b), (50, 100, 20)); +} + +#[test] +fn interpolate_topology_mismatch_returns_none() { + let a = triangle(0.0, 0.0); + let mut more_verts = triangle(0.0, 0.0); + more_verts.alloc_vertex(Point::new(999.0, 999.0)); + assert!(a.interpolated(&more_verts, 0.5).is_none(), "different vertex count"); + + // Same counts but a moved edge endpoint (different vertices) is still a mismatch. + let mut rewired = triangle(0.0, 0.0); + rewired.edges[0].vertices = [VertexId(2), VertexId(1)]; + assert!(a.interpolated(&rewired, 0.5).is_none(), "different edge endpoints"); +} diff --git a/lightningbeam-ui/lightningbeam-core/src/video.rs b/lightningbeam-ui/lightningbeam-core/src/video.rs index 444bd88..6182df7 100644 --- a/lightningbeam-ui/lightningbeam-core/src/video.rs +++ b/lightningbeam-ui/lightningbeam-core/src/video.rs @@ -97,7 +97,7 @@ impl VideoDecoder { // Optionally build keyframe index for fast seeking let keyframe_positions = if build_keyframes { eprintln!("[Video Decoder] Building keyframe index for {}", path); - let positions = Self::build_keyframe_index(&path, stream_index)?; + let positions = Self::scan_keyframes(&path, stream_index)?; eprintln!("[Video Decoder] Found {} keyframes", positions.len()); positions } else { @@ -125,14 +125,19 @@ impl VideoDecoder { }) } - /// Build keyframe index for this decoder - /// This can be called asynchronously after decoder creation - fn build_and_set_keyframe_index(&mut self) -> Result<(), String> { - eprintln!("[Video Decoder] Building keyframe index for {}", self.path); - let positions = Self::build_keyframe_index(&self.path, self.stream_index)?; - eprintln!("[Video Decoder] Found {} keyframes", positions.len()); + /// Source file path this decoder reads from. + pub fn path(&self) -> &str { + &self.path + } + + /// Parameters needed to scan keyframes off-thread (path + video stream index). + pub fn keyframe_scan_params(&self) -> (String, usize) { + (self.path.clone(), self.stream_index) + } + + /// Replace the keyframe index (built off-thread via [`VideoDecoder::scan_keyframes`]). + pub fn set_keyframe_index(&mut self, positions: Vec) { self.keyframe_positions = positions; - Ok(()) } /// Get the output width (scaled dimensions) @@ -150,9 +155,10 @@ impl VideoDecoder { self.get_frame(timestamp) } - /// Build an index of all keyframe positions in the video - /// This enables fast seeking by knowing exactly where keyframes are - fn build_keyframe_index(path: &str, stream_index: usize) -> Result, String> { + /// Build an index of all keyframe positions in the video by scanning packets + /// from a fresh input. Does not touch `self` — call it off-thread (it is slow + /// on long videos) and hand the result to [`VideoDecoder::set_keyframe_index`]. + pub fn scan_keyframes(path: &str, stream_index: usize) -> Result, String> { let mut input = ffmpeg::format::input(path) .map_err(|e| format!("Failed to open video for indexing: {}", e))?; @@ -340,6 +346,58 @@ impl VideoDecoder { } } +/// Generate timeline thumbnails for a video using a **dedicated** decoder that +/// is independent of any shared playback decoder — so thumbnail work never holds +/// a lock the UI/playback needs. +/// +/// Thumbnails are sampled at keyframes ~`interval_secs` apart. Decoding at a +/// keyframe is cheap (≈one frame) versus decoding forward to an arbitrary +/// timestamp (the whole GOP). Frames are decoded directly at `thumb_width` (so +/// `get_thumbnail_at`'s 128-wide assumption holds) and tightly packed RGBA is +/// handed to `on_thumb` as `(timestamp_secs, data)`. +pub fn generate_keyframe_thumbnails( + path: &str, + interval_secs: f64, + thumb_width: u32, + mut should_skip: impl FnMut(f64) -> bool, + mut on_thumb: impl FnMut(f64, Arc>), +) -> Result<(), String> { + // Own decoder at thumbnail resolution; builds its own keyframe index. The + // large max-height lets width be the constraining dimension, so output width + // is exactly `thumb_width`. + let mut decoder = VideoDecoder::new( + path.to_string(), + 4, + Some(thumb_width), + Some(100_000), + true, // build keyframe index (needed to sample at keyframes) + )?; + + let keyframe_secs: Vec = decoder + .keyframe_positions + .iter() + .map(|&ts| ts as f64 * decoder.time_base) + .collect(); + + let mut last_emitted = f64::NEG_INFINITY; + for ks in keyframe_secs { + if ks - last_emitted < interval_secs { + continue; + } + // This keyframe is a target slot; advance regardless of skip so the chosen + // slots are deterministic (lets a resumed pass target the same timestamps). + last_emitted = ks; + // Skip slots already covered (resume after a partial save / dedup). + if should_skip(ks) { + continue; + } + if let Ok(rgba) = decoder.get_frame(ks) { + on_thumb(ks, Arc::new(rgba)); + } + } + Ok(()) +} + /// Probe video file for metadata without creating a full decoder pub fn probe_video(path: &str) -> Result { ffmpeg::init().map_err(|e| e.to_string())?; @@ -407,18 +465,33 @@ pub struct VideoManager { /// Pool of video decoders, one per clip decoders: HashMap>>, - /// Frame cache: (clip_id, timestamp_ms) -> frame - /// Stores raw RGBA data for zero-copy rendering - frame_cache: HashMap<(Uuid, i64), Arc>, + /// Frame cache: (clip_id, timestamp_ms) -> frame. Stores decoded RGBA for + /// zero-copy rendering. Bounded by a **byte budget** (not a frame count, which + /// would be unsafe across resolutions — a 4K frame is ~33MB vs ~2MB at 800x600) + /// so playback of arbitrarily long video never grows unbounded. + frame_cache: LruCache<(Uuid, i64), Arc>, + /// Running total of bytes held in `frame_cache` (sum of each frame's RGBA len), + /// kept in sync on insert/evict/remove so eviction is O(1) per frame. + frame_cache_bytes: usize, /// Thumbnail cache: clip_id -> Vec of (timestamp, rgba_data) /// Low-resolution (64px width) thumbnails for scrubbing thumbnail_cache: HashMap>)>>, + /// Clips whose thumbnail generation finished. Only complete sets are worth + /// persisting — a partial set (saved mid-generation) is dropped so the load + /// regenerates it fully rather than leaving it permanently incomplete. + thumbnails_complete: std::collections::HashSet, + /// Maximum number of frames to cache per decoder cache_size: usize, } +/// Byte budget for [`VideoManager::frame_cache`] (decoded full-resolution frames). +/// At ~2MB/frame (800x600) this holds ~128 frames; at ~33MB/frame (4K) ~8 — in +/// both cases enough for the current frame plus a scrub window, while bounding RAM. +const FRAME_CACHE_BYTE_BUDGET: usize = 256 * 1024 * 1024; + impl VideoManager { /// Create a new video manager with default cache size pub fn new() -> Self { @@ -429,8 +502,10 @@ impl VideoManager { pub fn with_cache_size(cache_size: usize) -> Self { Self { decoders: HashMap::new(), - frame_cache: HashMap::new(), + frame_cache: LruCache::unbounded(), + frame_cache_bytes: 0, thumbnail_cache: HashMap::new(), + thumbnails_complete: std::collections::HashSet::new(), cache_size, } } @@ -440,8 +515,9 @@ impl VideoManager { /// `target_width` and `target_height` specify the maximum dimensions /// for decoded frames. Video will be scaled down if larger. /// - /// The keyframe index is NOT built during this call - use `build_keyframe_index_async` - /// in a background thread to build it asynchronously. + /// The keyframe index is NOT built during this call — scan it off-thread via + /// [`VideoDecoder::scan_keyframes`] and store it with + /// [`VideoDecoder::set_keyframe_index`] so the slow scan never blocks playback. pub fn load_video( &mut self, clip_id: Uuid, @@ -467,20 +543,6 @@ impl VideoManager { Ok(metadata) } - /// Build keyframe index for a loaded video asynchronously - /// - /// This should be called from a background thread after load_video() - /// to avoid blocking the UI during import. - pub fn build_keyframe_index(&self, clip_id: &Uuid) -> Result<(), String> { - let decoder_arc = self.decoders.get(clip_id) - .ok_or_else(|| format!("Video clip {} not found", clip_id))?; - - let mut decoder = decoder_arc.lock() - .map_err(|e| format!("Failed to lock decoder: {}", e))?; - - decoder.build_and_set_keyframe_index() - } - /// Get a decoded frame for a specific clip at a specific timestamp /// /// Returns None if the clip is not loaded or decoding fails. @@ -495,14 +557,16 @@ impl VideoManager { return Some(Arc::clone(cached_frame)); } - // Get decoder for this clip - let decoder_arc = self.decoders.get(clip_id)?; + // Get decoder for this clip. Clone the Arc so we don't hold a borrow of + // `self.decoders` across the `&mut self` cache insert below. + let decoder_arc = Arc::clone(self.decoders.get(clip_id)?); let mut decoder = decoder_arc.lock().ok()?; // Decode the frame let rgba_data = decoder.get_frame(timestamp).ok()?; let width = decoder.output_width; let height = decoder.output_height; + drop(decoder); // release the lock before touching `self` // Create VideoFrame and cache it let frame = Arc::new(VideoFrame { @@ -512,65 +576,27 @@ impl VideoManager { timestamp, }); - self.frame_cache.insert(cache_key, Arc::clone(&frame)); + self.cache_frame(cache_key, Arc::clone(&frame)); Some(frame) } - /// Generate thumbnails for a video clip (single batch version - use generate_thumbnails_progressive instead) - /// - /// Thumbnails are generated every 5 seconds at 128px width. - /// This should be called in a background thread to avoid blocking. - /// Thumbnails are inserted into the cache progressively as they're generated, - /// allowing the UI to display them immediately. - /// - /// DEPRECATED: Use generate_thumbnails_progressive which releases the lock between thumbnails. - pub fn generate_thumbnails(&mut self, clip_id: &Uuid, duration: f64) -> Result<(), String> { - let decoder_arc = self.decoders.get(clip_id) - .ok_or("Clip not loaded")? - .clone(); - - let mut decoder = decoder_arc.lock() - .map_err(|e| format!("Failed to lock decoder: {}", e))?; - - // Initialize thumbnail cache entry with empty vec - self.thumbnail_cache.insert(*clip_id, Vec::new()); - - let interval = 5.0; // Generate thumbnail every 5 seconds - let mut t = 0.0; - - while t < duration { - // Decode frame at this timestamp - if let Ok(rgba_data) = decoder.get_frame(t) { - // Decode already scaled to output dimensions, but we want 128px width for thumbnails - // We need to scale down further - let current_width = decoder.output_width; - let current_height = decoder.output_height; - - // Calculate thumbnail dimensions (128px width, maintain aspect ratio) - let thumb_width = 128u32; - let aspect_ratio = current_height as f32 / current_width as f32; - let thumb_height = (thumb_width as f32 * aspect_ratio) as u32; - - // Simple nearest-neighbor downsampling for thumbnails - let thumb_data = downsample_rgba( - &rgba_data, - current_width, - current_height, - thumb_width, - thumb_height, - ); - - // Insert thumbnail into cache immediately so UI can display it - if let Some(thumbnails) = self.thumbnail_cache.get_mut(clip_id) { - thumbnails.push((t, Arc::new(thumb_data))); - } - } - - t += interval; + /// Insert a frame into the byte-budgeted cache, evicting least-recently-used + /// frames until the total is within [`FRAME_CACHE_BYTE_BUDGET`]. + fn cache_frame(&mut self, key: (Uuid, i64), frame: Arc) { + let bytes = frame.rgba_data.len(); + if let Some(old) = self.frame_cache.put(key, frame) { + self.frame_cache_bytes = self.frame_cache_bytes.saturating_sub(old.rgba_data.len()); + } + self.frame_cache_bytes += bytes; + // Keep at least one frame resident even if it alone exceeds the budget. + while self.frame_cache_bytes > FRAME_CACHE_BYTE_BUDGET && self.frame_cache.len() > 1 { + if let Some((_, evicted)) = self.frame_cache.pop_lru() { + self.frame_cache_bytes = self.frame_cache_bytes.saturating_sub(evicted.rgba_data.len()); + } else { + break; + } } - - Ok(()) } /// Get the decoder Arc for a clip (for external thumbnail generation) @@ -579,18 +605,57 @@ impl VideoManager { self.decoders.get(clip_id).cloned() } - /// Insert a thumbnail into the cache (for external thumbnail generation) - pub fn insert_thumbnail(&mut self, clip_id: &Uuid, timestamp: f64, data: Arc>) { - self.thumbnail_cache - .entry(*clip_id) - .or_insert_with(Vec::new) - .push((timestamp, data)); + /// Snapshot all cached thumbnails for persistence (clip id -> sorted + /// (timestamp, rgba) pairs). Cheap: clones the `Arc`s, not the pixel data. + /// Partial sets are persisted too — pair with [`complete_thumbnail_clips`] so + /// the load knows which clips still need generation resumed. + pub fn snapshot_all_thumbnails(&self) -> HashMap>)>> { + self.thumbnail_cache.clone() } - /// Get the thumbnail closest to the specified timestamp + /// The set of clips whose thumbnail generation has finished (a full keyframe + /// pass). A persisted set flagged incomplete is resumed on load. + pub fn complete_thumbnail_clips(&self) -> std::collections::HashSet { + self.thumbnails_complete.clone() + } + + /// Mark a clip's thumbnail generation as complete (called when the background + /// generator finishes the full keyframe pass). + pub fn mark_thumbnails_complete(&mut self, clip_id: &Uuid) { + self.thumbnails_complete.insert(*clip_id); + } + + /// Whether the clip already has a thumbnail within `tol` seconds of `ts`. + /// Lets the generator skip keyframes already covered (resume / dedup). + pub fn has_thumbnail_near(&self, clip_id: &Uuid, ts: f64, tol: f64) -> bool { + self.thumbnail_cache + .get(clip_id) + .map_or(false, |v| v.iter().any(|(t, _)| (t - ts).abs() < tol)) + } + + /// Insert a thumbnail into the cache, keeping it **sorted by timestamp** and + /// **deduped** (an existing entry at the same timestamp is replaced). Sorted + /// order is required by `get_thumbnail_at`'s binary search, and dedup makes + /// concurrent restore + resumed generation idempotent (no double inserts). + pub fn insert_thumbnail(&mut self, clip_id: &Uuid, timestamp: f64, data: Arc>) { + let vec = self.thumbnail_cache.entry(*clip_id).or_default(); + match vec.binary_search_by(|(t, _)| { + t.partial_cmp(×tamp).unwrap_or(std::cmp::Ordering::Equal) + }) { + Ok(i) => vec[i] = (timestamp, data), + Err(i) => vec.insert(i, (timestamp, data)), + } + } + + /// Get the thumbnail closest to the specified timestamp. /// + /// Returns `(actual_timestamp, width, height, data)` — `actual_timestamp` is + /// the time of the thumbnail actually chosen (which may differ from the + /// requested `timestamp`, and changes as closer thumbnails finish generating). + /// Callers key their GPU texture cache on it so the on-clip strip refreshes as + /// better thumbnails load instead of freezing on the first one. /// Returns None if no thumbnails have been generated for this clip. - pub fn get_thumbnail_at(&self, clip_id: &Uuid, timestamp: f64) -> Option<(u32, u32, Arc>)> { + pub fn get_thumbnail_at(&self, clip_id: &Uuid, timestamp: f64) -> Option<(f64, u32, u32, Arc>)> { let thumbnails = self.thumbnail_cache.get(clip_id)?; if thumbnails.is_empty() { @@ -618,30 +683,43 @@ impl VideoManager { } }); - let (_, rgba_data) = &thumbnails[idx]; + let (actual_ts, rgba_data) = &thumbnails[idx]; - // Return (width, height, data) + // Return (actual_timestamp, width, height, data) // Thumbnails are always 128px width let thumb_width = 128; let thumb_height = (rgba_data.len() / (thumb_width * 4)) as u32; - Some((thumb_width as u32, thumb_height, Arc::clone(rgba_data))) + Some((*actual_ts, thumb_width as u32, thumb_height, Arc::clone(rgba_data))) } /// Remove a video clip and its cached data pub fn unload_video(&mut self, clip_id: &Uuid) { self.decoders.remove(clip_id); - // Remove all cached frames for this clip - self.frame_cache.retain(|(id, _), _| id != clip_id); + // Remove all cached frames for this clip (LruCache has no retain; collect + // matching keys, then pop each, keeping the byte total in sync). + let keys: Vec<(Uuid, i64)> = self + .frame_cache + .iter() + .filter(|((id, _), _)| id == clip_id) + .map(|(k, _)| *k) + .collect(); + for key in keys { + if let Some(frame) = self.frame_cache.pop(&key) { + self.frame_cache_bytes = self.frame_cache_bytes.saturating_sub(frame.rgba_data.len()); + } + } // Remove thumbnails self.thumbnail_cache.remove(clip_id); + self.thumbnails_complete.remove(clip_id); } /// Clear all frame caches (useful for memory management) pub fn clear_frame_cache(&mut self) { self.frame_cache.clear(); + self.frame_cache_bytes = 0; } } @@ -650,211 +728,3 @@ impl Default for VideoManager { Self::new() } } - -/// Simple nearest-neighbor downsampling for RGBA images -pub fn downsample_rgba_public( - src: &[u8], - src_width: u32, - src_height: u32, - dst_width: u32, - dst_height: u32, -) -> Vec { - downsample_rgba(src, src_width, src_height, dst_width, dst_height) -} - -/// Simple nearest-neighbor downsampling for RGBA images (internal) -fn downsample_rgba( - src: &[u8], - src_width: u32, - src_height: u32, - dst_width: u32, - dst_height: u32, -) -> Vec { - let mut dst = Vec::with_capacity((dst_width * dst_height * 4) as usize); - - let x_ratio = src_width as f32 / dst_width as f32; - let y_ratio = src_height as f32 / dst_height as f32; - - for y in 0..dst_height { - for x in 0..dst_width { - let src_x = (x as f32 * x_ratio) as u32; - let src_y = (y as f32 * y_ratio) as u32; - - let src_idx = ((src_y * src_width + src_x) * 4) as usize; - - // Copy RGBA bytes - dst.push(src[src_idx]); // R - dst.push(src[src_idx + 1]); // G - dst.push(src[src_idx + 2]); // B - dst.push(src[src_idx + 3]); // A - } - } - - dst -} - -/// Extracted audio data from a video file -#[derive(Debug, Clone)] -pub struct ExtractedAudio { - pub samples: Vec, - pub channels: u32, - pub sample_rate: u32, - pub duration: f64, -} - -/// Extract audio from a video file -/// -/// This function performs the slow FFmpeg decoding without holding any locks. -/// The caller can then quickly add the audio to the DAW backend in a background thread. -/// -/// Returns None if the video has no audio stream. -pub fn extract_audio_from_video(path: &str) -> Result, String> { - ffmpeg::init().map_err(|e| e.to_string())?; - - // Open video file - let mut input = ffmpeg::format::input(path) - .map_err(|e| format!("Failed to open video: {}", e))?; - - // Find audio stream - let audio_stream_opt = input.streams() - .best(ffmpeg::media::Type::Audio); - - // Return None if no audio stream - if audio_stream_opt.is_none() { - return Ok(None); - } - - let audio_stream = audio_stream_opt.unwrap(); - let audio_index = audio_stream.index(); - - // Get audio properties - let context_decoder = ffmpeg::codec::context::Context::from_parameters( - audio_stream.parameters() - ).map_err(|e| e.to_string())?; - - let mut audio_decoder = context_decoder.decoder().audio() - .map_err(|e| e.to_string())?; - - let sample_rate = audio_decoder.rate(); - let channels = audio_decoder.channels() as u32; - - // Decode all audio frames - let mut audio_samples: Vec = Vec::new(); - - for (stream, packet) in input.packets() { - if stream.index() == audio_index { - audio_decoder.send_packet(&packet) - .map_err(|e| e.to_string())?; - - let mut audio_frame = ffmpeg::util::frame::Audio::empty(); - while audio_decoder.receive_frame(&mut audio_frame).is_ok() { - // Convert audio to f32 packed format - let format = audio_frame.format(); - let frame_channels = audio_frame.channels() as usize; - - // Create resampler to convert to f32 packed - let mut resampler = ffmpeg::software::resampling::context::Context::get( - format, - audio_frame.channel_layout(), - sample_rate, - ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed), - audio_frame.channel_layout(), - sample_rate, - ).map_err(|e| e.to_string())?; - - let mut resampled_frame = ffmpeg::util::frame::Audio::empty(); - resampler.run(&audio_frame, &mut resampled_frame) - .map_err(|e| e.to_string())?; - - // Extract f32 samples (interleaved format) - let data_ptr = resampled_frame.data(0).as_ptr() as *const f32; - let total_samples = resampled_frame.samples() * frame_channels; - - // Safety checks before creating slice from FFmpeg data - // 1. Verify f32 alignment (required: 4 bytes) - if data_ptr.align_offset(std::mem::align_of::()) != 0 { - return Err("FFmpeg audio data is not properly aligned for f32".to_string()); - } - - // 2. Verify the frame actually has enough data - let byte_size = resampled_frame.data(0).len(); - let expected_bytes = total_samples * std::mem::size_of::(); - if byte_size < expected_bytes { - return Err(format!( - "FFmpeg frame buffer too small: {} bytes, need {} bytes", - byte_size, expected_bytes - )); - } - - // SAFETY: We verified alignment and bounds above. - // The slice lifetime is tied to resampled_frame which lives until - // after extend_from_slice completes. - let samples_slice = unsafe { - std::slice::from_raw_parts(data_ptr, total_samples) - }; - - audio_samples.extend_from_slice(samples_slice); - } - } - } - - // Flush audio decoder - audio_decoder.send_eof().map_err(|e| e.to_string())?; - let mut audio_frame = ffmpeg::util::frame::Audio::empty(); - while audio_decoder.receive_frame(&mut audio_frame).is_ok() { - let format = audio_frame.format(); - let frame_channels = audio_frame.channels() as usize; - - let mut resampler = ffmpeg::software::resampling::context::Context::get( - format, - audio_frame.channel_layout(), - sample_rate, - ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Packed), - audio_frame.channel_layout(), - sample_rate, - ).map_err(|e| e.to_string())?; - - let mut resampled_frame = ffmpeg::util::frame::Audio::empty(); - resampler.run(&audio_frame, &mut resampled_frame) - .map_err(|e| e.to_string())?; - - let data_ptr = resampled_frame.data(0).as_ptr() as *const f32; - let total_samples = resampled_frame.samples() * frame_channels; - - // Safety checks before creating slice from FFmpeg data - // 1. Verify f32 alignment (required: 4 bytes) - if data_ptr.align_offset(std::mem::align_of::()) != 0 { - return Err("FFmpeg audio data is not properly aligned for f32".to_string()); - } - - // 2. Verify the frame actually has enough data - let byte_size = resampled_frame.data(0).len(); - let expected_bytes = total_samples * std::mem::size_of::(); - if byte_size < expected_bytes { - return Err(format!( - "FFmpeg frame buffer too small: {} bytes, need {} bytes", - byte_size, expected_bytes - )); - } - - // SAFETY: We verified alignment and bounds above. - // The slice lifetime is tied to resampled_frame which lives until - // after extend_from_slice completes. - let samples_slice = unsafe { - std::slice::from_raw_parts(data_ptr, total_samples) - }; - - audio_samples.extend_from_slice(samples_slice); - } - - // Calculate duration - let total_samples_per_channel = audio_samples.len() / channels as usize; - let duration = total_samples_per_channel as f64 / sample_rate as f64; - - Ok(Some(ExtractedAudio { - samples: audio_samples, - channels, - sample_rate, - duration, - })) -} diff --git a/lightningbeam-ui/lightningbeam-core/tests/beam_archive.rs b/lightningbeam-ui/lightningbeam-core/tests/beam_archive.rs new file mode 100644 index 0000000..a0d4b04 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/tests/beam_archive.rs @@ -0,0 +1,207 @@ +//! Integration tests for the SQLite-backed `.beam` container. +//! +//! These are integration tests (not `#[cfg(test)]` unit tests) so they build the +//! library in normal mode and exercise only the public API — independent of any +//! pre-existing breakage in the crate's internal test modules. + +use lightningbeam_core::beam_archive::{BeamArchive, MediaKind, MediaMeta, MediaStorage}; +use std::io::{Read, Seek, SeekFrom}; +use std::sync::atomic::{AtomicU64, Ordering}; +use uuid::Uuid; + +fn temp_db_path(tag: &str) -> std::path::PathBuf { + static N: AtomicU64 = AtomicU64::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + let mut p = std::env::temp_dir(); + p.push(format!("beam_archive_it_{}_{}_{}.beam", std::process::id(), tag, n)); + let _ = std::fs::remove_file(&p); + p +} + +#[test] +fn project_json_roundtrip() { + let path = temp_db_path("json"); + let archive = BeamArchive::create(&path).unwrap(); + archive.set_project_json("{\"hello\":\"world\"}").unwrap(); + assert_eq!(archive.get_project_json().unwrap(), "{\"hello\":\"world\"}"); + drop(archive); + let archive = BeamArchive::open(&path).unwrap(); + assert_eq!(archive.get_project_json().unwrap(), "{\"hello\":\"world\"}"); + assert!(BeamArchive::is_sqlite(&path)); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn packed_media_roundtrip_full() { + let path = temp_db_path("full"); + let mut archive = BeamArchive::create(&path).unwrap(); + let id = Uuid::from_u128(0x1234); + archive.set_chunk_size(1000); + let data: Vec = (0..3500u32).map(|i| (i % 251) as u8).collect(); + archive + .put_media_packed( + id, + MediaKind::Audio, + "flac", + &data, + MediaMeta { channels: Some(2), sample_rate: Some(44100), ..Default::default() }, + ) + .unwrap(); + + let info = archive.media_info(id).unwrap().unwrap(); + assert_eq!(info.kind, MediaKind::Audio); + assert_eq!(info.codec, "flac"); + assert_eq!(info.storage, MediaStorage::Packed); + assert_eq!(info.total_len, 3500); + assert_eq!(info.channels, Some(2)); + assert_eq!(info.sample_rate, Some(44100)); + + assert_eq!(archive.read_media_full(id).unwrap(), data); + assert_eq!(archive.media_ids_of_kind(MediaKind::Audio).unwrap(), vec![id]); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn blob_reader_streams_and_seeks() { + let path = temp_db_path("stream"); + let mut archive = BeamArchive::create(&path).unwrap(); + archive.set_chunk_size(1000); + let id = Uuid::from_u128(0xBEEF); + let data: Vec = (0..3500u32).map(|i| (i % 251) as u8).collect(); + archive + .put_media_packed(id, MediaKind::Audio, "mp3", &data, MediaMeta::default()) + .unwrap(); + + let mut reader = archive.open_blob_reader(&path, id).unwrap(); + assert_eq!(reader.len(), 3500); + + // Sequential read in odd-sized buffers crosses chunk boundaries. + let mut got = Vec::new(); + let mut buf = [0u8; 333]; + loop { + let n = reader.read(&mut buf).unwrap(); + if n == 0 { + break; + } + got.extend_from_slice(&buf[..n]); + } + assert_eq!(got, data); + + // Seek to a mid-chunk position and read across a boundary. + reader.seek(SeekFrom::Start(990)).unwrap(); + let mut window = [0u8; 20]; + let mut filled = 0; + while filled < window.len() { + let n = reader.read(&mut window[filled..]).unwrap(); + assert!(n > 0); + filled += n; + } + assert_eq!(&window[..], &data[990..1010]); + + // Seek from end and read the tail. + reader.seek(SeekFrom::End(-10)).unwrap(); + let mut tail = Vec::new(); + reader.read_to_end(&mut tail).unwrap(); + assert_eq!(tail, &data[3490..]); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn referenced_media_records_path() { + let path = temp_db_path("ref"); + let mut archive = BeamArchive::create(&path).unwrap(); + let id = Uuid::from_u128(0xCAFE); + archive + .put_media_referenced( + id, + MediaKind::Video, + "mp4", + "/mnt/share/big.mp4", + MediaMeta { width: Some(3840), height: Some(2160), ..Default::default() }, + ) + .unwrap(); + let info = archive.media_info(id).unwrap().unwrap(); + assert_eq!(info.storage, MediaStorage::Referenced); + assert_eq!(info.ext_path.as_deref(), Some("/mnt/share/big.mp4")); + assert_eq!(info.width, Some(3840)); + // Streaming a referenced item is an error (caller opens the path directly). + assert!(archive.open_blob_reader(&path, id).is_err()); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn transaction_groups_writes_and_orphan_cleanup() { + let path = temp_db_path("txn"); + let keep = Uuid::from_u128(1); + let orphan = Uuid::from_u128(2); + + // First save: two media items committed in one transaction. + { + let mut archive = BeamArchive::create(&path).unwrap(); + let txn = archive.transaction().unwrap(); + txn.put_media_packed(keep, MediaKind::Audio, "flac", &vec![9u8; 10], MediaMeta::default()) + .unwrap(); + txn.put_media_packed(orphan, MediaKind::Audio, "mp3", &vec![8u8; 10], MediaMeta::default()) + .unwrap(); + txn.set_project_json("{}").unwrap(); + txn.commit().unwrap(); + } + { + let archive = BeamArchive::open(&path).unwrap(); + assert!(archive.media_info(keep).unwrap().is_some()); + assert!(archive.media_info(orphan).unwrap().is_some()); + } + + // Second save (in place): keep only `keep`; `orphan` should be retained-out. + { + let mut archive = BeamArchive::open(&path).unwrap(); + let txn = archive.transaction().unwrap(); + // `keep` already present → in-place save leaves it untouched. + assert!(txn.media_exists(keep).unwrap()); + let mut live = std::collections::HashSet::new(); + live.insert(keep); + let removed = txn.retain_media(&live).unwrap(); + assert_eq!(removed, 1); + txn.commit().unwrap(); + } + { + let archive = BeamArchive::open(&path).unwrap(); + assert!(archive.media_info(keep).unwrap().is_some()); + assert!(archive.media_info(orphan).unwrap().is_none()); + // `keep`'s bytes survived untouched. + assert_eq!(archive.read_media_full(keep).unwrap(), vec![9u8; 10]); + } + let _ = std::fs::remove_file(&path); +} + +#[test] +fn rolled_back_transaction_writes_nothing() { + let path = temp_db_path("rollback"); + let id = Uuid::from_u128(42); + let mut archive = BeamArchive::create(&path).unwrap(); + { + let txn = archive.transaction().unwrap(); + txn.put_media_packed(id, MediaKind::Audio, "flac", &vec![1u8; 5], MediaMeta::default()) + .unwrap(); + // Drop without commit → rollback. + } + assert!(archive.media_info(id).unwrap().is_none()); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn overwrite_media_replaces_chunks() { + let path = temp_db_path("overwrite"); + let mut archive = BeamArchive::create(&path).unwrap(); + archive.set_chunk_size(100); + let id = Uuid::from_u128(7); + archive + .put_media_packed(id, MediaKind::Raster, "png", &vec![1u8; 250], MediaMeta::default()) + .unwrap(); + // Overwrite with shorter data — stale chunks must be gone. + archive + .put_media_packed(id, MediaKind::Raster, "png", &vec![2u8; 50], MediaMeta::default()) + .unwrap(); + assert_eq!(archive.read_media_full(id).unwrap(), vec![2u8; 50]); + let _ = std::fs::remove_file(&path); +} diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index 8d36026..053c4c6 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightningbeam-editor" -version = "1.0.4-alpha" +version = "1.0.5-alpha" edition = "2021" description = "Multimedia editor for audio, video and 2D animation" license = "GPL-3.0-or-later" diff --git a/lightningbeam-ui/lightningbeam-editor/src/config.rs b/lightningbeam-ui/lightningbeam-editor/src/config.rs index a1efa3b..24c976e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/config.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/config.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use crate::keymap::KeybindingConfig; +use lightningbeam_core::file_io::LargeMediaMode; /// Application configuration (persistent) #[derive(Debug, Clone, Serialize, Deserialize)] @@ -57,6 +58,18 @@ pub struct AppConfig { /// Custom keyboard shortcut overrides (sparse — only non-default bindings stored) #[serde(default)] pub keybindings: KeybindingConfig, + + /// How to store media files at/above the large-media threshold (~2GB). + /// `Ask` (default) prompts the first time such a file is imported, then the + /// chosen mode is persisted here. Reset to `Ask` to be prompted again. + #[serde(default)] + pub large_media_default: LargeMediaMode, + + /// Finest-level resolution of the waveform LOD pyramid: source frames per + /// floor texel (`B`). Smaller = larger on-disk pyramid but zoom-in re-decodes + /// sooner; larger = smaller pyramid, wider re-decode span. Default 256. + #[serde(default = "defaults::waveform_floor_samples_per_texel")] + pub waveform_floor_samples_per_texel: u32, } impl Default for AppConfig { @@ -75,6 +88,8 @@ impl Default for AppConfig { waveform_stereo: defaults::waveform_stereo(), theme_mode: defaults::theme_mode(), keybindings: KeybindingConfig::default(), + large_media_default: LargeMediaMode::default(), + waveform_floor_samples_per_texel: defaults::waveform_floor_samples_per_texel(), } } } @@ -276,4 +291,5 @@ mod defaults { pub fn debug() -> bool { false } pub fn waveform_stereo() -> bool { false } pub fn theme_mode() -> String { "system".to_string() } + pub fn waveform_floor_samples_per_texel() -> u32 { 256 } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/debug_overlay.rs b/lightningbeam-ui/lightningbeam-editor/src/debug_overlay.rs index 5dc3a87..1def09e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/debug_overlay.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/debug_overlay.rs @@ -39,6 +39,33 @@ pub fn update_prepare_timing( t.composite_ms = composite_ms; } } +/// GPU memory the editor tracks itself (wgpu has no allocator query). Currently the +/// raster-layer texture cache — the only unbounded-by-default VRAM consumer. +#[derive(Debug, Clone, Default)] +pub struct GpuMemoryStats { + pub raster_cache_entries: usize, + pub raster_cache_bytes: usize, +} + +static GPU_MEMORY: OnceLock> = OnceLock::new(); + +/// Called by the GPU brush whenever the raster-layer cache changes. +pub fn update_gpu_memory(raster_cache_entries: usize, raster_cache_bytes: usize) { + let cell = GPU_MEMORY.get_or_init(|| Mutex::new(GpuMemoryStats::default())); + if let Ok(mut s) = cell.lock() { + s.raster_cache_entries = raster_cache_entries; + s.raster_cache_bytes = raster_cache_bytes; + } +} + +fn get_gpu_memory() -> GpuMemoryStats { + GPU_MEMORY + .get_or_init(|| Mutex::new(GpuMemoryStats::default())) + .lock() + .map(|s| s.clone()) + .unwrap_or_default() +} + const DEVICE_REFRESH_INTERVAL: Duration = Duration::from_secs(2); // Refresh devices every 2 seconds const MEMORY_REFRESH_INTERVAL: Duration = Duration::from_millis(500); // Refresh memory every 500ms @@ -52,6 +79,7 @@ pub struct DebugStats { pub frame_time_ms: f32, // Current frame time in milliseconds pub memory_physical_mb: usize, pub memory_virtual_mb: usize, + pub gpu_memory: GpuMemoryStats, pub gpu_name: String, pub gpu_backend: String, pub gpu_driver: String, @@ -218,6 +246,7 @@ impl DebugStatsCollector { frame_time_ms, memory_physical_mb, memory_virtual_mb, + gpu_memory: get_gpu_memory(), gpu_name, gpu_backend, gpu_driver, @@ -286,6 +315,11 @@ pub fn render_debug_overlay(ctx: &egui::Context, stats: &DebugStats) { ui.colored_label(egui::Color32::YELLOW, format!("Memory: ({}µs)", stats.timing_memory_us)); ui.label(format!("Physical: {} MB", stats.memory_physical_mb)); ui.label(format!("Virtual: {} MB", stats.memory_virtual_mb)); + ui.label(format!( + "VRAM (raster cache): {:.1} MB ({} frames)", + stats.gpu_memory.raster_cache_bytes as f64 / (1024.0 * 1024.0), + stats.gpu_memory.raster_cache_entries, + )); ui.add_space(8.0); diff --git a/lightningbeam-ui/lightningbeam-editor/src/effect_thumbnails.rs b/lightningbeam-ui/lightningbeam-editor/src/effect_thumbnails.rs index 698b4a9..894f725 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/effect_thumbnails.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/effect_thumbnails.rs @@ -11,6 +11,36 @@ use uuid::Uuid; /// Size of effect thumbnails in pixels pub const EFFECT_THUMBNAIL_SIZE: u32 = 64; +use lightningbeam_core::gpu::{srgb_to_linear, linear_to_srgb}; + +/// sRGB-u8 RGBA → linear-`f16` RGBA bytes (little-endian). Feeds the effect +/// shaders linear light at float precision, matching the live HDR pipeline (an +/// 8-bit linear intermediate would band in shadows). RGB go through the sRGB +/// EOTF; alpha is linear. +fn srgb_image_to_linear_f16(rgba: &[u8]) -> Vec { + let mut out = Vec::with_capacity(rgba.len() * 2); + for px in rgba.chunks_exact(4) { + for &c in &px[..3] { + out.extend_from_slice(&half::f16::from_f32(srgb_to_linear(c as f32 / 255.0)).to_le_bytes()); + } + out.extend_from_slice(&half::f16::from_f32(px[3] as f32 / 255.0).to_le_bytes()); + } + out +} + +/// linear-`f16` RGBA bytes → sRGB-u8 RGBA. Inverse of [`srgb_image_to_linear_f16`]. +fn linear_f16_to_srgb_image(f16_rgba: &[u8]) -> Vec { + let mut out = Vec::with_capacity(f16_rgba.len() / 2); + for texel in f16_rgba.chunks_exact(8) { + let ch = |i: usize| half::f16::from_le_bytes([texel[i], texel[i + 1]]).to_f32(); + out.push((linear_to_srgb(ch(0)) * 255.0 + 0.5) as u8); + out.push((linear_to_srgb(ch(2)) * 255.0 + 0.5) as u8); + out.push((linear_to_srgb(ch(4)) * 255.0 + 0.5) as u8); + out.push((ch(6).clamp(0.0, 1.0) * 255.0 + 0.5) as u8); + } + out +} + /// Embedded still-life image for effect preview thumbnails const EFFECT_PREVIEW_IMAGE_BYTES: &[u8] = include_bytes!("../../../src/assets/still-life.jpg"); @@ -39,10 +69,18 @@ impl EffectThumbnailGenerator { /// Create a new effect thumbnail generator pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self { // Load and decode the source image - let source_rgba = Self::load_source_image(); + // The effect shaders operate in LINEAR light (matching the live HDR + // pipeline, which feeds them a linear Rgba16Float texture). The preview + // image is sRGB-encoded, so linearize it before upload and re-encode the + // result after readback. This keeps thumbnails consistent with the live + // render for every effect, including the gamma-space perceptual ones. + // Linearize to f16 (float precision — an 8-bit linear intermediate would + // band in shadows, the reason the live canvas is Rgba16Float). + let source_f16 = srgb_image_to_linear_f16(&Self::load_source_image()); - // Create effect processor (using Rgba8Unorm for thumbnail output) - let effect_processor = EffectProcessor::new(device, wgpu::TextureFormat::Rgba8Unorm); + // Effect processor + textures use Rgba16Float linear, matching the live + // pipeline so thumbnails render identically to the on-canvas effect. + let effect_processor = EffectProcessor::new(device, wgpu::TextureFormat::Rgba16Float); // Create source texture let source_texture = device.create_texture(&wgpu::TextureDescriptor { @@ -55,12 +93,12 @@ impl EffectThumbnailGenerator { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, + format: wgpu::TextureFormat::Rgba16Float, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); - // Upload source image data + // Upload source image data (Rgba16Float = 8 bytes/texel). queue.write_texture( wgpu::TexelCopyTextureInfo { texture: &source_texture, @@ -68,10 +106,10 @@ impl EffectThumbnailGenerator { origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, - &source_rgba, + &source_f16, wgpu::TexelCopyBufferLayout { offset: 0, - bytes_per_row: Some(EFFECT_THUMBNAIL_SIZE * 4), + bytes_per_row: Some(EFFECT_THUMBNAIL_SIZE * 8), rows_per_image: Some(EFFECT_THUMBNAIL_SIZE), }, wgpu::Extent3d { @@ -94,17 +132,15 @@ impl EffectThumbnailGenerator { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, + format: wgpu::TextureFormat::Rgba16Float, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let dest_view = dest_texture.create_view(&wgpu::TextureViewDescriptor::default()); - // Create readback buffer - let _buffer_size = (EFFECT_THUMBNAIL_SIZE * EFFECT_THUMBNAIL_SIZE * 4) as u64; - // Align to 256 bytes for wgpu requirements - let aligned_bytes_per_row = ((EFFECT_THUMBNAIL_SIZE * 4 + 255) / 256) * 256; + // Create readback buffer (Rgba16Float = 8 bytes/texel, rows 256-aligned). + let aligned_bytes_per_row = ((EFFECT_THUMBNAIL_SIZE * 8 + 255) / 256) * 256; let readback_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("effect_thumbnail_readback"), size: (aligned_bytes_per_row * EFFECT_THUMBNAIL_SIZE) as u64, @@ -248,8 +284,8 @@ impl EffectThumbnailGenerator { return None; } - // Copy result to readback buffer - let aligned_bytes_per_row = ((EFFECT_THUMBNAIL_SIZE * 4 + 255) / 256) * 256; + // Copy result to readback buffer (Rgba16Float = 8 bytes/texel). + let aligned_bytes_per_row = ((EFFECT_THUMBNAIL_SIZE * 8 + 255) / 256) * 256; encoder.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: &self.dest_texture, @@ -291,20 +327,21 @@ impl EffectThumbnailGenerator { return None; } - // Copy data from mapped buffer (handling row alignment) + // De-stride the linear-f16 result (drop the 256-byte row padding). let data = buffer_slice.get_mapped_range(); - let mut rgba = Vec::with_capacity((EFFECT_THUMBNAIL_SIZE * EFFECT_THUMBNAIL_SIZE * 4) as usize); - + let row_tight = (EFFECT_THUMBNAIL_SIZE * 8) as usize; + let mut f16_rgba = Vec::with_capacity(row_tight * EFFECT_THUMBNAIL_SIZE as usize); for row in 0..EFFECT_THUMBNAIL_SIZE { let row_start = (row * aligned_bytes_per_row) as usize; - let row_end = row_start + (EFFECT_THUMBNAIL_SIZE * 4) as usize; - rgba.extend_from_slice(&data[row_start..row_end]); + f16_rgba.extend_from_slice(&data[row_start..row_start + row_tight]); } drop(data); self.readback_buffer.unmap(); - Some(rgba) + // Result is linear f16 (the effect ran in linear light); re-encode to + // sRGB-u8 for display, mirroring the live pipeline's linear→sRGB output. + Some(linear_f16_to_srgb_image(&f16_rgba)) } /// Get all effect IDs that have pending thumbnail requests diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/cpu_yuv_converter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/cpu_yuv_converter.rs index 6572b4c..2ba087c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/cpu_yuv_converter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/cpu_yuv_converter.rs @@ -13,6 +13,11 @@ use ffmpeg_next as ffmpeg; pub struct CpuYuvConverter { width: u32, height: u32, + /// swscale context + reusable source/dest frames, built once and reused every frame + /// (creating them per call was a measurable per-output-frame export cost). + scaler: ffmpeg::software::scaling::Context, + rgba_frame: ffmpeg::frame::Video, + yuv_frame: ffmpeg::frame::Video, } impl CpuYuvConverter { @@ -22,7 +27,20 @@ impl CpuYuvConverter { /// * `width` - Frame width in pixels /// * `height` - Frame height in pixels pub fn new(width: u32, height: u32) -> Result { - Ok(Self { width, height }) + // BT.709 (HD) RGBA→YUV420p context, created once. + let scaler = ffmpeg::software::scaling::Context::get( + ffmpeg::format::Pixel::RGBA, + width, + height, + ffmpeg::format::Pixel::YUV420P, + width, + height, + ffmpeg::software::scaling::Flags::BILINEAR, + ) + .map_err(|e| format!("Failed to create swscale context: {}", e))?; + let rgba_frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, width, height); + let yuv_frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::YUV420P, width, height); + Ok(Self { width, height, scaler, rgba_frame, yuv_frame }) } /// Convert RGBA data to YUV420p planes @@ -40,7 +58,7 @@ impl CpuYuvConverter { /// /// # Panics /// Panics if rgba_data length doesn't match width * height * 4 - pub fn convert(&self, rgba_data: &[u8]) -> Result<(Vec, Vec, Vec), String> { + pub fn convert(&mut self, rgba_data: &[u8]) -> Result<(Vec, Vec, Vec), String> { let expected_size = (self.width * self.height * 4) as usize; assert_eq!( rgba_data.len(), @@ -50,51 +68,17 @@ impl CpuYuvConverter { rgba_data.len() ); - // Create source RGBA frame - let mut rgba_frame = ffmpeg::frame::Video::new( - ffmpeg::format::Pixel::RGBA, - self.width, - self.height, - ); - - // Copy RGBA data into source frame - // ffmpeg-next provides mutable access to the frame data - let frame_data = rgba_frame.data_mut(0); - frame_data.copy_from_slice(rgba_data); - - // Create destination YUV420p frame - let mut yuv_frame = ffmpeg::frame::Video::new( - ffmpeg::format::Pixel::YUV420P, - self.width, - self.height, - ); - - // Create swscale context for RGBA→YUV420p conversion - // Uses BT.709 color matrix (HD standard) - let mut scaler = ffmpeg::software::scaling::Context::get( - ffmpeg::format::Pixel::RGBA, - self.width, - self.height, - ffmpeg::format::Pixel::YUV420P, - self.width, - self.height, - ffmpeg::software::scaling::Flags::BILINEAR, - ) - .map_err(|e| format!("Failed to create swscale context: {}", e))?; - - // Perform the conversion (SIMD-optimized) - scaler - .run(&rgba_frame, &mut yuv_frame) + // Copy RGBA into the reused source frame, run the reused scaler into the reused + // dest frame (SIMD-optimized), then extract planes. + self.rgba_frame.data_mut(0).copy_from_slice(rgba_data); + self.scaler + .run(&self.rgba_frame, &mut self.yuv_frame) .map_err(|e| format!("swscale conversion failed: {}", e))?; - // Extract planar YUV data - // YUV420p has 3 planes: - // - Y: full resolution (width × height) - // - U: quarter resolution (width/2 × height/2) - // - V: quarter resolution (width/2 × height/2) - let y_plane = yuv_frame.data(0).to_vec(); - let u_plane = yuv_frame.data(1).to_vec(); - let v_plane = yuv_frame.data(2).to_vec(); + // YUV420p planes: Y full-res, U/V quarter-res (2×2 subsampled). + let y_plane = self.yuv_frame.data(0).to_vec(); + let u_plane = self.yuv_frame.data(1).to_vec(); + let v_plane = self.yuv_frame.data(2).to_vec(); Ok((y_plane, u_plane, v_plane)) } @@ -112,7 +96,7 @@ mod tests { #[test] fn test_conversion_output_sizes() { - let converter = CpuYuvConverter::new(1920, 1080).unwrap(); + let mut converter = CpuYuvConverter::new(1920, 1080).unwrap(); // Create dummy RGBA data (all black) let rgba_data = vec![0u8; 1920 * 1080 * 4]; @@ -133,7 +117,7 @@ mod tests { #[test] #[should_panic(expected = "RGBA data size mismatch")] fn test_wrong_input_size_panics() { - let converter = CpuYuvConverter::new(1920, 1080).unwrap(); + let mut converter = CpuYuvConverter::new(1920, 1080).unwrap(); // Wrong size input let rgba_data = vec![0u8; 1000]; diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs index 957d415..7515764 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs @@ -113,10 +113,10 @@ struct ParallelExportState { video_progress_rx: Receiver, /// Audio progress channel audio_progress_rx: Receiver, - /// Video encoder thread handle - video_thread: std::thread::JoinHandle<()>, - /// Audio export thread handle - audio_thread: std::thread::JoinHandle<()>, + /// Video encoder thread handle (taken when the mux thread is spawned). + video_thread: Option>, + /// Audio export thread handle (taken when the mux thread is spawned). + audio_thread: Option>, /// Temporary video file path temp_video_path: PathBuf, /// Temporary audio file path @@ -127,6 +127,9 @@ struct ParallelExportState { video_progress: Option, /// Latest audio progress audio_progress: Option, + /// Result channel for the background mux. `Some` once muxing has started; the + /// mux runs off the UI thread so the app stays responsive during finalization. + mux_rx: Option>>, } impl ExportOrchestrator { @@ -220,47 +223,33 @@ impl ExportOrchestrator { parallel.audio_progress = Some(progress); } - // Check if both are complete - let video_complete = matches!(parallel.video_progress, Some(ExportProgress::Complete { .. })); - let audio_complete = matches!(parallel.audio_progress, Some(ExportProgress::Complete { .. })); - - if video_complete && audio_complete { - println!("🎬🎵 [PARALLEL] Both video and audio complete, starting mux"); - - // Take parallel state to extract file paths - let parallel_state = self.parallel_export.take().unwrap(); - - // Wait for threads to finish - parallel_state.video_thread.join().ok(); - parallel_state.audio_thread.join().ok(); - - // Start muxing - match Self::mux_video_and_audio( - ¶llel_state.temp_video_path, - ¶llel_state.temp_audio_path, - ¶llel_state.final_output_path, - ) { - Ok(()) => { + // If a background mux is already running, poll it without blocking the UI. + if parallel.mux_rx.is_some() { + match parallel.mux_rx.as_ref().unwrap().try_recv() { + Ok(Ok(())) => { println!("✅ [MUX] Muxing complete, cleaning up temp files"); - - // Clean up temp files - std::fs::remove_file(¶llel_state.temp_video_path).ok(); - std::fs::remove_file(¶llel_state.temp_audio_path).ok(); - - return Some(ExportProgress::Complete { - output_path: parallel_state.final_output_path, - }); + let state = self.parallel_export.take().unwrap(); + std::fs::remove_file(&state.temp_video_path).ok(); + std::fs::remove_file(&state.temp_audio_path).ok(); + return Some(ExportProgress::Complete { output_path: state.final_output_path }); } - Err(err) => { + Ok(Err(err)) => { println!("❌ [MUX] Muxing failed: {}", err); - return Some(ExportProgress::Error { - message: format!("Muxing failed: {}", err), - }); + self.parallel_export = None; + return Some(ExportProgress::Error { message: format!("Muxing failed: {}", err) }); + } + Err(std::sync::mpsc::TryRecvError::Empty) => { + // Still muxing — keep the UI responsive and show finalizing state. + return Some(ExportProgress::Finalizing); + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.parallel_export = None; + return Some(ExportProgress::Error { message: "Mux thread terminated unexpectedly".to_string() }); } } } - // Check for errors + // Check for errors before completion. if let Some(ExportProgress::Error { ref message }) = parallel.video_progress { return Some(ExportProgress::Error { message: format!("Video: {}", message) }); } @@ -268,6 +257,30 @@ impl ExportOrchestrator { return Some(ExportProgress::Error { message: format!("Audio: {}", message) }); } + // Both streams done → spawn the mux on a background thread (the previous + // implementation muxed synchronously here on the UI thread, which froze the + // app for the whole re-mux pass after progress already hit 100%). + let video_complete = matches!(parallel.video_progress, Some(ExportProgress::Complete { .. })); + let audio_complete = matches!(parallel.audio_progress, Some(ExportProgress::Complete { .. })); + if video_complete && audio_complete { + println!("🎬🎵 [PARALLEL] Both video and audio complete, starting background mux"); + let video_thread = parallel.video_thread.take(); + let audio_thread = parallel.audio_thread.take(); + let video_path = parallel.temp_video_path.clone(); + let audio_path = parallel.temp_audio_path.clone(); + let output_path = parallel.final_output_path.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + // The export threads have signalled Complete; join is near-instant. + if let Some(t) = video_thread { t.join().ok(); } + if let Some(t) = audio_thread { t.join().ok(); } + let result = Self::mux_video_and_audio(&video_path, &audio_path, &output_path); + tx.send(result).ok(); + }); + parallel.mux_rx = Some(rx); + return Some(ExportProgress::Finalizing); + } + // Return combined progress match (¶llel.video_progress, ¶llel.audio_progress) { (Some(ExportProgress::FrameRendered { frame, total }), _) => { @@ -371,90 +384,90 @@ impl ExportOrchestrator { println!("🎵 [MUX] Audio stream - Input TB: {}/{}, Output TB: {}/{}", audio_input_tb.0, audio_input_tb.1, audio_output_tb.0, audio_output_tb.1); - // Collect all packets with their stream info and timestamps - let mut video_packets = Vec::new(); - for (stream, packet) in video_input.packets() { - if stream.index() == video_stream_index { - video_packets.push(packet); + // Stream-merge the two inputs by PTS, writing each packet as it's read — + // O(1) memory (one pending packet per stream) instead of collecting every + // packet first, so muxing a long export never grows unbounded. + let video_idx = video_stream_index; + let audio_idx = audio_stream_index; + let mut v_iter = video_input.packets(); + let mut a_iter = audio_input.packets(); + + // Pull the next packet belonging to the desired stream from each input. + let mut next_video = move || -> Option { + loop { + match v_iter.next() { + Some((stream, packet)) => { + if stream.index() == video_idx { + return Some(packet); + } + } + None => return None, + } } - } - - let mut audio_packets = Vec::new(); - for (stream, packet) in audio_input.packets() { - if stream.index() == audio_stream_index { - audio_packets.push(packet); + }; + let mut next_audio = move || -> Option { + loop { + match a_iter.next() { + Some((stream, packet)) => { + if stream.index() == audio_idx { + return Some(packet); + } + } + None => return None, + } } - } + }; - println!("🎬 [MUX] Collected {} video packets, {} audio packets", - video_packets.len(), audio_packets.len()); + let mut pending_v = next_video(); + let mut pending_a = next_audio(); + let mut v_count = 0usize; + let mut a_count = 0usize; + let mut log_count = 0; - // Report first and last timestamps - if !video_packets.is_empty() { - println!("🎬 [MUX] Video PTS range: {} to {}", - video_packets[0].pts().unwrap_or(0), - video_packets[video_packets.len()-1].pts().unwrap_or(0)); - } - if !audio_packets.is_empty() { - println!("🎵 [MUX] Audio PTS range: {} to {}", - audio_packets[0].pts().unwrap_or(0), - audio_packets[audio_packets.len()-1].pts().unwrap_or(0)); - } - - // Interleave packets by comparing timestamps in a common time base (use microseconds) - let mut v_idx = 0; - let mut a_idx = 0; - let mut interleave_log_count = 0; - - while v_idx < video_packets.len() || a_idx < audio_packets.len() { - let write_video = if v_idx >= video_packets.len() { - false // No more video - } else if a_idx >= audio_packets.len() { - true // No more audio, write video - } else { - // Compare timestamps - convert both to microseconds - let v_pts = video_packets[v_idx].pts().unwrap_or(0); - let a_pts = audio_packets[a_idx].pts().unwrap_or(0); - - // Convert to microseconds: pts * 1000000 * tb.num / tb.den - let v_us = v_pts * 1_000_000 * video_input_tb.0 as i64 / video_input_tb.1 as i64; - let a_us = a_pts * 1_000_000 * audio_input_tb.0 as i64 / audio_input_tb.1 as i64; - - v_us <= a_us // Write video if it comes before or at same time as audio + loop { + // Write whichever pending packet has the earlier PTS (in a common + // microsecond base); when one stream is exhausted, drain the other. + let write_video = match (&pending_v, &pending_a) { + (None, None) => break, + (Some(_), None) => true, + (None, Some(_)) => false, + (Some(v), Some(a)) => { + let v_us = v.pts().unwrap_or(0) * 1_000_000 * video_input_tb.0 as i64 + / video_input_tb.1 as i64; + let a_us = a.pts().unwrap_or(0) * 1_000_000 * audio_input_tb.0 as i64 + / audio_input_tb.1 as i64; + v_us <= a_us + } }; if write_video { - let mut packet = video_packets[v_idx].clone(); + let mut packet = pending_v.take().unwrap(); packet.set_stream(0); packet.rescale_ts(video_input_tb, video_output_tb); - - if interleave_log_count < 10 { - println!("🎬 [MUX] Writing V packet {} - PTS={:?}, DTS={:?}, Duration={:?}", - v_idx, packet.pts(), packet.dts(), packet.duration()); - interleave_log_count += 1; + if log_count < 10 { + println!("🎬 [MUX] Writing V packet - PTS={:?}, DTS={:?}", packet.pts(), packet.dts()); + log_count += 1; } - packet.write_interleaved(&mut output) .map_err(|e| format!("Failed to write video packet: {}", e))?; - v_idx += 1; + v_count += 1; + pending_v = next_video(); } else { - let mut packet = audio_packets[a_idx].clone(); + let mut packet = pending_a.take().unwrap(); packet.set_stream(1); packet.rescale_ts(audio_input_tb, audio_output_tb); - - if interleave_log_count < 10 { - println!("🎵 [MUX] Writing A packet {} - PTS={:?}, DTS={:?}, Duration={:?}", - a_idx, packet.pts(), packet.dts(), packet.duration()); - interleave_log_count += 1; + if log_count < 10 { + println!("🎵 [MUX] Writing A packet - PTS={:?}, DTS={:?}", packet.pts(), packet.dts()); + log_count += 1; } - packet.write_interleaved(&mut output) .map_err(|e| format!("Failed to write audio packet: {}", e))?; - a_idx += 1; + a_count += 1; + pending_a = next_audio(); } } - println!("🎬 [MUX] Wrote {} video packets, {} audio packets", v_idx, a_idx); + println!("🎬 [MUX] Wrote {} video packets, {} audio packets", v_count, a_count); // Write trailer output.write_trailer().map_err(|e| format!("Failed to write trailer: {}", e))?; @@ -515,6 +528,7 @@ impl ExportOrchestrator { image_cache: &mut ImageCache, video_manager: &Arc>, floating_selection: Option<&lightningbeam_core::selection::RasterFloatingSelection>, + raster_store: Option<&lightningbeam_core::raster_store::RasterStore>, ) -> Result { if self.cancel_flag.load(Ordering::Relaxed) { self.image_state = None; @@ -562,6 +576,7 @@ impl ExportOrchestrator { output_view, floating_selection, state.settings.allow_transparency, + raster_store, )?; queue.submit(Some(encoder.finish())); @@ -979,13 +994,14 @@ impl ExportOrchestrator { self.parallel_export = Some(ParallelExportState { video_progress_rx, audio_progress_rx, - video_thread, - audio_thread, + video_thread: Some(video_thread), + audio_thread: Some(audio_thread), temp_video_path, temp_audio_path, final_output_path: output_path, video_progress: None, audio_progress: None, + mux_rx: None, }); println!("🎬🎵 [PARALLEL EXPORT] Both threads spawned, ready for frames"); @@ -1015,6 +1031,7 @@ impl ExportOrchestrator { renderer: &mut vello::Renderer, image_cache: &mut ImageCache, video_manager: &Arc>, + raster_store: Option<&lightningbeam_core::raster_store::RasterStore>, ) -> Result { use std::time::Instant; @@ -1112,6 +1129,7 @@ impl ExportOrchestrator { gpu_resources, &acquired.rgba_texture_view, None, // No floating selection during video export false, // Video export is never transparent + raster_store, )?; let render_end = Instant::now(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs index ec8b7fc..1d1fdcc 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/video_exporter.rs @@ -191,7 +191,9 @@ impl ExportGpuResources { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("linear_to_srgb_shader"), - source: wgpu::ShaderSource::Wgsl(LINEAR_TO_SRGB_SHADER.into()), + source: wgpu::ShaderSource::Wgsl( + format!("{}\n{}", lightningbeam_core::gpu::COLOR_WGSL, LINEAR_TO_SRGB_SHADER).into(), + ), }); let linear_to_srgb_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { @@ -310,32 +312,24 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { return out; } -// Linear to sRGB color space conversion (per channel) -fn linear_to_srgb_channel(c: f32) -> f32 { - return select( - 1.055 * pow(c, 1.0 / 2.4) - 0.055, - c * 12.92, - c <= 0.0031308 - ); -} - -fn linear_to_srgb(color: vec3) -> vec3 { - return vec3( - linear_to_srgb_channel(color.r), - linear_to_srgb_channel(color.g), - linear_to_srgb_channel(color.b) - ); -} +// linear_to_srgb / linear_to_srgb_channel are provided by the prepended +// COLOR_WGSL prelude (see the create_shader_module call site). @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { let src = textureSample(source_tex, source_sampler, in.uv); - // Convert linear HDR to sRGB - let srgb = linear_to_srgb(src.rgb); + // The compositor accumulates PREMULTIPLIED linear color. Unpremultiply + // before the sRGB OETF (srgb(rgb*a) != srgb(rgb)*a) and emit STRAIGHT + // alpha, which is what PNG export / the readback path expect. For opaque + // pixels (a == 1, the normal video case) this is an exact identity. + let a = src.a; + let straight = select(src.rgb / a, vec3(0.0), a <= 0.0); - // Alpha stays unchanged - return vec4(srgb, src.a); + // Convert linear HDR to sRGB + let srgb = linear_to_srgb(straight); + + return vec4(srgb, a); } "#; @@ -522,12 +516,27 @@ pub fn setup_video_encoder( encoder.set_bit_rate((bitrate_kbps * 1000) as usize); encoder.set_gop(framerate as u32); // 1 second GOP + // Tag the color metadata so players interpret the YUV correctly. Our + // RGB→YUV conversion uses the BT.709 matrix with FULL-range (0–255) luma + // and no transfer applied to the already-sRGB-encoded RGB. Tagging this + // as full-range BT.709 (matrix/primaries/transfer) prevents the level/ + // hue shift that occurs when a player assumes limited-range or BT.601. + // colorspace (matrix) and range have safe setters; primaries and trc are + // generic AVCodecContext options set via the open dictionary below. + encoder.set_colorspace(ffmpeg::color::Space::BT709); + encoder.set_color_range(ffmpeg::color::Range::JPEG); // full range + println!("📐 Video dimensions: {}×{} (aligned to {}×{} for H.264)", width, height, aligned_width, aligned_height); - // Open encoder with codec (like working MP3 export) + // Open encoder with codec (like working MP3 export). color_primaries and + // color_trc have no typed setter on the encoder, so pass them as generic + // AVCodecContext options (BT.709) through the open dictionary. + let mut color_opts = ffmpeg::Dictionary::new(); + color_opts.set("color_primaries", "bt709"); + color_opts.set("color_trc", "bt709"); let encoder = encoder - .open_as(codec) + .open_as_with(codec, color_opts) .map_err(|e| format!("Failed to open video encoder: {}", e))?; Ok((encoder, codec)) @@ -973,8 +982,18 @@ pub fn render_frame_to_rgba_hdr( // Set document time to the frame timestamp document.current_time = timestamp; - // Use identity transform for export (document coordinates = pixel coordinates) - let base_transform = Affine::IDENTITY; + // Scale the document to the export resolution. The core renderer bakes this + // base transform into every layer (vector scenes, raster and video layer + // transforms), so the whole stage scales up/down to fill the output. When the + // export size matches the document this is the identity. + let base_transform = if document.width > 0.0 && document.height > 0.0 { + Affine::scale_non_uniform( + width as f64 / document.width, + height as f64 / document.height, + ) + } else { + Affine::IDENTITY + }; // Render document for compositing (returns per-layer scenes) let composite_result = render_document_for_compositing( @@ -1160,6 +1179,39 @@ pub fn render_frame_to_rgba_hdr( /// /// # Returns /// Command encoder ready for submission (caller submits via ReadbackPipeline) +/// Fault in raster keyframe pixels needed to composite the document at its current +/// time, decoding them from the project `.beam` container via `raster_store`. +/// +/// Mutates the document in place: for every raster layer's active keyframe whose +/// `raw_pixels` are empty, loads + sets them (and marks `texture_dirty`). A no-op +/// when `raster_store` is `None`/unsaved or everything is already resident. +fn fault_in_raster_for_frame( + document: &mut Document, + raster_store: Option<&lightningbeam_core::raster_store::RasterStore>, +) { + let store = match raster_store { + Some(s) if s.has_path() => s, + _ => return, + }; + let now = document.current_time; + for layer in document.all_layers_mut() { + if let lightningbeam_core::layer::AnyLayer::Raster(rl) = layer { + // Resolve the active keyframe id at the current time, then fault it in. + let kf_id = match rl.keyframe_at(now) { + Some(kf) if kf.raw_pixels.is_empty() && kf.needs_fault_in => kf.id, + _ => continue, + }; + if let Some(kf) = rl.keyframes.iter_mut().find(|kf| kf.id == kf_id) { + if let Some(pixels) = store.load_pixels(kf_id) { + kf.raw_pixels = pixels; + kf.texture_dirty = true; + } + kf.needs_fault_in = false; + } + } + } +} + pub fn render_frame_to_gpu_rgba( document: &mut Document, timestamp: f64, @@ -1174,14 +1226,31 @@ pub fn render_frame_to_gpu_rgba( rgba_texture_view: &wgpu::TextureView, floating_selection: Option<&lightningbeam_core::selection::RasterFloatingSelection>, allow_transparency: bool, + raster_store: Option<&lightningbeam_core::raster_store::RasterStore>, ) -> Result { use vello::kurbo::Affine; // Set document time to the frame timestamp document.current_time = timestamp; - // Use identity transform for export (document coordinates = pixel coordinates) - let base_transform = Affine::IDENTITY; + // Fault in raster keyframe pixels for this frame (Phase 3 paging). Offline + // export renders synchronously with no "next frame", so unlike the live canvas + // we must page the pixels in here, before compositing. Cheap no-op when every + // keyframe is already resident or when the document is unsaved (no store path). + fault_in_raster_for_frame(document, raster_store); + + // Scale the document to the export resolution. The core renderer bakes this + // base transform into every layer (vector scenes, raster and video layer + // transforms), so the whole stage scales up/down to fill the output. When the + // export size matches the document this is the identity. + let base_transform = if document.width > 0.0 && document.height > 0.0 { + Affine::scale_non_uniform( + width as f64 / document.width, + height as f64 / document.height, + ) + } else { + Affine::IDENTITY + }; // Render document for compositing (returns per-layer scenes) let composite_result = render_document_for_compositing( diff --git a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs index 5958737..0c83c56 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs @@ -24,24 +24,94 @@ use lightningbeam_core::brush_engine::GpuDab; // Colour-space helpers // --------------------------------------------------------------------------- -/// Decode one sRGB-encoded byte to linear float [0, 1]. -fn srgb_to_linear(c: f32) -> f32 { - if c <= 0.04045 { - c / 12.92 - } else { - ((c + 0.055) / 1.055).powf(2.4) - } +use lightningbeam_core::gpu::srgb_to_linear; + +// Lookup tables that keep the per-pixel `powf`/f16 conversions out of the canvas +// upload/readback loops. Doing the sRGB transfer per pixel was ~110ms for an +// 800x600 readback in a debug build; precomputing it once turns each channel +// into a table index. + +/// Upload encode: sRGB byte → linear f16 little-endian bytes (for RGB channels). +fn srgb_to_linear_f16_lut() -> &'static [[u8; 2]; 256] { + static LUT: std::sync::OnceLock<[[u8; 2]; 256]> = std::sync::OnceLock::new(); + LUT.get_or_init(|| { + let mut lut = [[0u8; 2]; 256]; + for (i, out) in lut.iter_mut().enumerate() { + *out = half::f16::from_f32(srgb_to_linear(i as f32 / 255.0)).to_le_bytes(); + } + lut + }) } -/// Encode one linear float [0, 1] to an sRGB-encoded byte. -fn linear_to_srgb_byte(c: u8) -> u8 { - let f = c as f32 / 255.0; - let encoded = if f <= 0.0031308 { - f * 12.92 - } else { - 1.055 * f.powf(1.0 / 2.4) - 0.055 - }; - (encoded * 255.0 + 0.5) as u8 +/// Upload encode: byte → linear f16 little-endian bytes (for the alpha channel, +/// which is not gamma-encoded). +fn linear_f16_lut() -> &'static [[u8; 2]; 256] { + static LUT: std::sync::OnceLock<[[u8; 2]; 256]> = std::sync::OnceLock::new(); + LUT.get_or_init(|| { + let mut lut = [[0u8; 2]; 256]; + for (i, out) in lut.iter_mut().enumerate() { + *out = half::f16::from_f32(i as f32 / 255.0).to_le_bytes(); + } + lut + }) +} + +// --------------------------------------------------------------------------- +// Incremental ping-pong sync +// --------------------------------------------------------------------------- + +/// Tile size (px) for incremental canvas sync copies between the ping-pong +/// textures. The brush keeps both textures identical; each frame only the tiles +/// touched by that frame's dabs are copied, so this bounds both the wasted bytes +/// per touched tile and the number of copy regions. +const SYNC_TILE: u32 = 128; + +/// Coalesced copy rectangles (x, y, w, h) covering the tiles touched by `dabs`, +/// clamped to the canvas. Adjacent tiles in a row are merged into one rectangle +/// to keep the number of `copy_texture_to_texture` calls small. +fn dirty_tile_rects(dabs: &[GpuDab], canvas_w: u32, canvas_h: u32) -> Vec<(u32, u32, u32, u32)> { + if canvas_w == 0 || canvas_h == 0 || dabs.is_empty() { + return Vec::new(); + } + let tiles_x = canvas_w.div_ceil(SYNC_TILE); + let tiles_y = canvas_h.div_ceil(SYNC_TILE); + let mut mask = vec![false; (tiles_x * tiles_y) as usize]; + + for d in dabs { + let r = d.radius + 1.0; + // Dab pixel bbox clamped to the canvas, then mapped to tile indices. + let px0 = (d.x - r).floor().clamp(0.0, (canvas_w - 1) as f32) as u32; + let py0 = (d.y - r).floor().clamp(0.0, (canvas_h - 1) as f32) as u32; + let px1 = (d.x + r).ceil().clamp(0.0, (canvas_w - 1) as f32) as u32; + let py1 = (d.y + r).ceil().clamp(0.0, (canvas_h - 1) as f32) as u32; + for ty in (py0 / SYNC_TILE)..=(py1 / SYNC_TILE) { + for tx in (px0 / SYNC_TILE)..=(px1 / SYNC_TILE) { + mask[(ty * tiles_x + tx) as usize] = true; + } + } + } + + // Merge horizontal runs of set tiles in each tile-row into one rectangle. + let mut rects = Vec::new(); + for ty in 0..tiles_y { + let mut tx = 0; + while tx < tiles_x { + if mask[(ty * tiles_x + tx) as usize] { + let run_start = tx; + while tx < tiles_x && mask[(ty * tiles_x + tx) as usize] { + tx += 1; + } + let x = run_start * SYNC_TILE; + let y = ty * SYNC_TILE; + let w = (tx * SYNC_TILE).min(canvas_w) - x; + let h = ((ty + 1) * SYNC_TILE).min(canvas_h) - y; + rects.push((x, y, w, h)); + } else { + tx += 1; + } + } + } + rects } // --------------------------------------------------------------------------- @@ -68,7 +138,7 @@ impl CanvasPair { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, + format: wgpu::TextureFormat::Rgba16Float, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC @@ -93,18 +163,24 @@ impl CanvasPair { /// `pixels` is expected to be **sRGB-encoded premultiplied** (the format stored /// in `raw_pixels` / PNG files). The values are decoded to linear premultiplied /// before being written to the canvas, which operates entirely in linear space. + /// The canvas is `Rgba16Float`, so linear values are stored at 16-bit float + /// precision — storing linear light in 8 bits would band badly in shadows. pub fn upload(&self, queue: &wgpu::Queue, pixels: &[u8]) { - // Decode sRGB-premultiplied → linear premultiplied for the GPU canvas. + // Decode sRGB-premultiplied → linear premultiplied f16 for the GPU canvas. + // LUT-driven so there is no per-pixel powf / float conversion. + let rgb_lut = srgb_to_linear_f16_lut(); + let a_lut = linear_f16_lut(); let linear: Vec = pixels.chunks_exact(4).flat_map(|p| { - let r = (srgb_to_linear(p[0] as f32 / 255.0) * 255.0 + 0.5) as u8; - let g = (srgb_to_linear(p[1] as f32 / 255.0) * 255.0 + 0.5) as u8; - let b = (srgb_to_linear(p[2] as f32 / 255.0) * 255.0 + 0.5) as u8; - [r, g, b, p[3]] + let r = rgb_lut[p[0] as usize]; + let g = rgb_lut[p[1] as usize]; + let b = rgb_lut[p[2] as usize]; + let a = a_lut[p[3] as usize]; + [r[0], r[1], g[0], g[1], b[0], b[1], a[0], a[1]] }).collect(); let layout = wgpu::TexelCopyBufferLayout { offset: 0, - bytes_per_row: Some(self.width * 4), + bytes_per_row: Some(self.width * 8), // Rgba16Float = 8 bytes/texel rows_per_image: Some(self.height), }; let extent = wgpu::Extent3d { @@ -204,7 +280,7 @@ impl RasterTransformPipeline { visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, - format: wgpu::TextureFormat::Rgba8Unorm, + format: wgpu::TextureFormat::Rgba16Float, view_dimension: wgpu::TextureViewDimension::D2, }, count: None, @@ -384,7 +460,7 @@ impl WarpApplyPipeline { visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, - format: wgpu::TextureFormat::Rgba8Unorm, + format: wgpu::TextureFormat::Rgba16Float, view_dimension: wgpu::TextureViewDimension::D2, }, count: None, @@ -596,7 +672,7 @@ impl LiquifyBrushPipeline { // Gradient-fill pipeline // --------------------------------------------------------------------------- -/// One gradient stop on the GPU side. Colors are linear straight-alpha [0..1]. +/// One gradient stop on the GPU side. Colors are sRGB straight-alpha [0..1]. /// Must be 32 bytes (8 × f32) to match `GradientStop` in `gradient_fill.wgsl`. #[repr(C)] #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] @@ -611,13 +687,18 @@ pub struct GpuGradientStop { impl GpuGradientStop { /// Construct from sRGB u8 bytes (as stored in `ShapeColor`). - /// RGB is converted to linear; alpha is kept linear (not gamma-encoded). + /// + /// Stops are kept in sRGB (gamma) space so the shader interpolates between + /// them in gamma space — matching the CPU raster path (`Gradient::eval`) and + /// the vector/peniko path, and the gamma-space gradients users expect from + /// tools like Photoshop/Flash. The shader converts the interpolated color to + /// linear before compositing into the linear canvas. pub fn from_srgb_u8(position: f32, r: u8, g: u8, b: u8, a: u8) -> Self { Self { position, - r: srgb_to_linear(r as f32 / 255.0), - g: srgb_to_linear(g as f32 / 255.0), - b: srgb_to_linear(b as f32 / 255.0), + r: r as f32 / 255.0, + g: g as f32 / 255.0, + b: b as f32 / 255.0, a: a as f32 / 255.0, _pad: [0.0; 3], } @@ -654,7 +735,7 @@ impl GradientFillPipeline { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("gradient_fill_shader"), source: wgpu::ShaderSource::Wgsl( - include_str!("panes/shaders/gradient_fill.wgsl").into(), + color_wgsl(include_str!("panes/shaders/gradient_fill.wgsl")).into(), ), }); @@ -701,7 +782,7 @@ impl GradientFillPipeline { visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, - format: wgpu::TextureFormat::Rgba8Unorm, + format: wgpu::TextureFormat::Rgba16Float, view_dimension: wgpu::TextureViewDimension::D2, }, count: None, @@ -780,9 +861,76 @@ impl GradientFillPipeline { /// Compute pipeline: composites the scratch buffer C over the source A → output B. /// /// Binding layout (see `alpha_composite.wgsl`): -/// 0 = tex_a (texture_2d, Rgba8Unorm, sampled, not filterable) -/// 1 = tex_c (texture_2d, Rgba8Unorm, sampled, not filterable) +/// 0 = tex_a (texture_2d, Rgba16Float, sampled, not filterable) +/// 1 = tex_c (texture_2d, Rgba16Float, sampled, not filterable) /// 2 = tex_b (texture_storage_2d) +/// Prepend the shared WGSL sRGB color functions ([`COLOR_WGSL`]) to a shader +/// source so the OETF/EOTF live in exactly one place. +fn color_wgsl(shader_src: &str) -> String { + format!("{}\n{}", lightningbeam_core::gpu::COLOR_WGSL, shader_src) +} + +/// A compute `texture_2d` sampled binding (non-filterable). +fn sampled_tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + } +} + +/// A compute write-only storage-texture binding of the given format. +fn storage_tex_entry(binding: u32, format: wgpu::TextureFormat) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + } +} + +/// Build a compute pipeline + matching bind-group layout from WGSL source and +/// layout entries (entry point `main`). Collapses the otherwise-identical +/// pipeline/layout construction boilerplate shared by the compute pipelines. +fn build_compute_pipeline( + device: &wgpu::Device, + label: &str, + shader_src: &str, + entries: &[wgpu::BindGroupLayoutEntry], +) -> (wgpu::ComputePipeline, wgpu::BindGroupLayout) { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(label), + source: wgpu::ShaderSource::Wgsl(shader_src.into()), + }); + let bg_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some(label), + entries, + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(label), + bind_group_layouts: &[&bg_layout], + push_constant_ranges: &[], + }); + let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(label), + layout: Some(&layout), + module: &shader, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }); + (pipeline, bg_layout) +} + struct AlphaCompositePipeline { pipeline: wgpu::ComputePipeline, bg_layout: wgpu::BindGroupLayout, @@ -790,56 +938,78 @@ struct AlphaCompositePipeline { impl AlphaCompositePipeline { fn new(device: &wgpu::Device) -> Self { - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("alpha_composite_shader"), - source: wgpu::ShaderSource::Wgsl( - include_str!("panes/shaders/alpha_composite.wgsl").into(), - ), - }); - let sampled_entry = |binding: u32| wgpu::BindGroupLayoutEntry { - binding, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }; - let bg_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("alpha_composite_bgl"), - entries: &[ - sampled_entry(0), // tex_a - sampled_entry(1), // tex_c - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::COMPUTE, - ty: wgpu::BindingType::StorageTexture { - access: wgpu::StorageTextureAccess::WriteOnly, - format: wgpu::TextureFormat::Rgba8Unorm, - view_dimension: wgpu::TextureViewDimension::D2, - }, - count: None, - }, + let (pipeline, bg_layout) = build_compute_pipeline( + device, + "alpha_composite", + include_str!("panes/shaders/alpha_composite.wgsl"), + &[ + sampled_tex_entry(0), // tex_a + sampled_tex_entry(1), // tex_c + storage_tex_entry(2, wgpu::TextureFormat::Rgba16Float), // tex_b (out) ], - }); - let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("alpha_composite_layout"), - bind_group_layouts: &[&bg_layout], - push_constant_ranges: &[], - }); - let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("alpha_composite_pipeline"), - layout: Some(&layout), - module: &shader, - entry_point: Some("main"), - compilation_options: Default::default(), - cache: None, - }); + ); Self { pipeline, bg_layout } } } +/// Compute pipeline that converts the linear-premultiplied `Rgba16Float` canvas +/// into an `Rgba8Unorm` sRGB-premultiplied texture for fast readback. +struct ReadbackSrgbPipeline { + pipeline: wgpu::ComputePipeline, + bg_layout: wgpu::BindGroupLayout, +} + +impl ReadbackSrgbPipeline { + fn new(device: &wgpu::Device) -> Self { + let (pipeline, bg_layout) = build_compute_pipeline( + device, + "canvas_readback_srgb", + &color_wgsl(include_str!("panes/shaders/canvas_readback_srgb.wgsl")), + &[ + sampled_tex_entry(0), // src (linear) + storage_tex_entry(1, wgpu::TextureFormat::Rgba8Unorm), // dst (sRGB) + ], + ); + Self { pipeline, bg_layout } + } +} + +/// Reusable scratch for `readback_canvas`: the Rgba8Unorm conversion target plus +/// its MAP_READ staging buffer, kept across calls and rebuilt only on size change. +struct ReadbackScratch { + width: u32, + height: u32, + view: wgpu::TextureView, + tex: wgpu::Texture, + staging: wgpu::Buffer, + /// 256-aligned bytes-per-row of the staging buffer (Rgba8 = 4 B/texel). + bytes_per_row_aligned: u32, +} + +impl ReadbackScratch { + fn new(device: &wgpu::Device, width: u32, height: u32) -> Self { + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("canvas_readback_srgb"), + size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + let bytes_per_row_aligned = ((width * 4 + 255) / 256) * 256; + let staging = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("canvas_readback_buf"), + size: (bytes_per_row_aligned * height) as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + Self { width, height, view, tex, staging, bytes_per_row_aligned } + } +} + // GpuBrushEngine // --------------------------------------------------------------------------- @@ -860,18 +1030,36 @@ pub struct GpuBrushEngine { /// Lazily created on first unified-tool composite dispatch. composite_pipeline: Option, + /// Lazily-created pipeline converting the canvas to sRGB for fast readback. + readback_srgb_pipeline: Option, + + /// Reused scratch (texture + staging buffer) for `readback_canvas`, recreated + /// only when the canvas size changes, to avoid per-stroke GPU allocations. + readback_scratch: Option, + /// Canvas texture pairs keyed by keyframe UUID. pub canvases: HashMap, /// Displacement map buffers keyed by a caller-supplied UUID. pub displacement_bufs: HashMap, - /// Persistent `Rgba8Unorm` textures for idle raster layers. + /// Persistent `Rgba16Float` textures for idle raster layers. /// /// Keyed by keyframe UUID (same ID space as `canvases`). Entries are uploaded /// once when `RasterKeyframe::texture_dirty` is set, then reused every frame. /// Separate from `canvases` so tool teardown never accidentally removes them. pub raster_layer_cache: HashMap, + /// Recency order for `raster_layer_cache` (least-recent first), bumped on every + /// `ensure_layer_texture` so the visible frames stay at the back. Scrubbing a long + /// timeline would otherwise grow this map without bound (~w·h·16 bytes of VRAM per + /// entry); we evict the oldest once it exceeds `RASTER_LAYER_CACHE_MAX`. + raster_layer_lru: Vec, + /// Low-res proxy textures (one small `CanvasPair` per keyframe), shown while the + /// full-res pixels page in. Keyed by keyframe id; separate from + /// `raster_layer_cache` so a proxy and its full frame never collide. Bounded by + /// its own (generous, since each is tiny) recency LRU. + proxy_layer_cache: HashMap, + proxy_layer_lru: Vec, } /// CPU-side parameters uniform for the compute shader. @@ -889,8 +1077,18 @@ struct DabParams { } impl GpuBrushEngine { + /// Max number of idle raster-layer textures kept resident in VRAM. Scrubbing a + /// long timeline would otherwise cache one ~`w·h·16`-byte pair per visited frame + /// without bound; the least-recently-used are evicted past this (re-uploaded on + /// revisit from the faulted-in pixels). + const RASTER_LAYER_CACHE_MAX: usize = 12; + + /// Proxies are ~1/100th the VRAM of a full frame, so we can keep many resident + /// for instant cold-scrub display before evicting the least-recently-used. + const RASTER_PROXY_CACHE_MAX: usize = 64; + /// Create the pipeline. Returns `Err` if the device lacks the required - /// storage-texture capability for `Rgba8Unorm`. + /// storage-texture capability for `Rgba16Float`. pub fn new(device: &wgpu::Device) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("brush_dab_shader"), @@ -942,7 +1140,7 @@ impl GpuBrushEngine { visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, - format: wgpu::TextureFormat::Rgba8Unorm, + format: wgpu::TextureFormat::Rgba16Float, view_dimension: wgpu::TextureViewDimension::D2, }, count: None, @@ -978,9 +1176,14 @@ impl GpuBrushEngine { liquify_brush_pipeline: None, gradient_fill_pipeline: None, composite_pipeline: None, + readback_srgb_pipeline: None, + readback_scratch: None, canvases: HashMap::new(), displacement_bufs: HashMap::new(), raster_layer_cache: HashMap::new(), + raster_layer_lru: Vec::new(), + proxy_layer_cache: HashMap::new(), + proxy_layer_lru: Vec::new(), } } @@ -1022,12 +1225,14 @@ impl GpuBrushEngine { ) { if dabs.is_empty() { return; } - // Smudge dabs must be applied one at a time so each dab reads the canvas - // state written by the previous dab. Use bbox-only copies (union of current - // and previous dab) to avoid an expensive full-canvas copy per dab. + // render_dabs_batch keeps both ping-pong textures identical after every + // call, so smudge — whose each dab must read the previous dab's output — + // simply dispatches one dab at a time: each per-dab call leaves src fully + // authoritative for the next. Paint/erase dabs are independent within a + // frame (the shader accumulates them over the shared source), so they + // dispatch together as one batch. let is_smudge = dabs.first().map(|d| d.blend_mode == 2).unwrap_or(false); if is_smudge { - let mut prev_bbox: Option<(i32, i32, i32, i32)> = None; for dab in dabs { let r = dab.radius + 1.0; let cur_bbox = ( @@ -1036,31 +1241,24 @@ impl GpuBrushEngine { (dab.x + r).ceil() as i32, (dab.y + r).ceil() as i32, ); - // Expand copy region to include the previous dab's bbox so the - // pixels it wrote are visible as the source for this dab's smudge. - let copy_bbox = match prev_bbox { - Some(pb) => (cur_bbox.0.min(pb.0), cur_bbox.1.min(pb.1), - cur_bbox.2.max(pb.2), cur_bbox.3.max(pb.3)), - None => cur_bbox, - }; self.render_dabs_batch(device, queue, keyframe_id, - std::slice::from_ref(dab), cur_bbox, Some(copy_bbox), canvas_w, canvas_h); - prev_bbox = Some(cur_bbox); + std::slice::from_ref(dab), cur_bbox, canvas_w, canvas_h); } } else { - self.render_dabs_batch(device, queue, keyframe_id, dabs, bbox, None, canvas_w, canvas_h); + self.render_dabs_batch(device, queue, keyframe_id, dabs, bbox, canvas_w, canvas_h); } } - /// Inner batch dispatch. + /// Dispatch one batch of dabs and keep the ping-pong textures identical. /// - /// `dispatch_bbox` — region dispatched to the compute shader (usually the union of all dab bboxes). - /// `copy_bbox` — region to copy src→dst before dispatch: - /// - `None` → copy the full canvas (required for paint/erase batches so - /// dabs outside the current frame's region are preserved). - /// - `Some(r)` → copy only region `r` (sufficient for sequential smudge dabs - /// because both textures hold identical data outside previously - /// touched regions, so no full copy is needed). + /// Reads `src` and writes the result over `dispatch_bbox` into `dst`, then + /// copies only the tiles the dabs touched back from `dst` to `src` so both + /// textures stay authoritative — no full-canvas copy. The textures start equal + /// (seeded by `upload` at stroke start) and this preserves that invariant, so a + /// single dab per call is enough for smudge's read-after-write dependency. + /// + /// `dispatch_bbox` is the region dispatched to the compute shader (the union of + /// the batch's dab bboxes). fn render_dabs_batch( &mut self, device: &wgpu::Device, @@ -1068,7 +1266,6 @@ impl GpuBrushEngine { keyframe_id: Uuid, dabs: &[GpuDab], dispatch_bbox: (i32, i32, i32, i32), - copy_bbox: Option<(i32, i32, i32, i32)>, canvas_w: u32, canvas_h: u32, ) { @@ -1088,61 +1285,7 @@ impl GpuBrushEngine { let bbox_w = x1 - x0; let bbox_h = y1 - y0; - // Step 1: Copy src→dst. - // For paint/erase batches (copy_bbox = None): copy the ENTIRE canvas so dst - // starts with all previous dabs — a bbox-only copy would lose dabs outside - // this frame's region after swap. - // For smudge (copy_bbox = Some(r)): copy only the union of the current and - // previous dab bboxes. Outside that region both textures hold identical - // data so no full copy is needed. - let mut copy_enc = device.create_command_encoder( - &wgpu::CommandEncoderDescriptor { label: Some("canvas_copy_encoder") }, - ); - match copy_bbox { - None => { - copy_enc.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: canvas.src(), - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyTextureInfo { - texture: canvas.dst(), - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::Extent3d { width: canvas_w, height: canvas_h, depth_or_array_layers: 1 }, - ); - } - Some(cb) => { - let cx0 = cb.0.max(0) as u32; - let cy0 = cb.1.max(0) as u32; - let cx1 = (cb.2 as u32).min(canvas_w); - let cy1 = (cb.3 as u32).min(canvas_h); - if cx1 > cx0 && cy1 > cy0 { - copy_enc.copy_texture_to_texture( - wgpu::TexelCopyTextureInfo { - texture: canvas.src(), - mip_level: 0, - origin: wgpu::Origin3d { x: cx0, y: cy0, z: 0 }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::TexelCopyTextureInfo { - texture: canvas.dst(), - mip_level: 0, - origin: wgpu::Origin3d { x: cx0, y: cy0, z: 0 }, - aspect: wgpu::TextureAspect::All, - }, - wgpu::Extent3d { width: cx1 - cx0, height: cy1 - cy0, depth_or_array_layers: 1 }, - ); - } - } - } - queue.submit(Some(copy_enc.finish())); - - // Step 2: Upload all dabs as a single storage buffer. + // Step 1: Upload all dabs as a single storage buffer. let dab_bytes = bytemuck::cast_slice(dabs); let dab_buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some("dab_storage_buf"), @@ -1181,7 +1324,7 @@ impl GpuBrushEngine { ], }); - // Step 3: Single dispatch over the union bounding box. + // Step 2: Single dispatch over the union bounding box. let mut compute_enc = device.create_command_encoder( &wgpu::CommandEncoderDescriptor { label: Some("brush_dab_encoder") }, ); @@ -1193,52 +1336,98 @@ impl GpuBrushEngine { pass.set_bind_group(0, &bg, &[]); pass.dispatch_workgroups(bbox_w.div_ceil(8), bbox_h.div_ceil(8), 1); } + + // Step 3: Sync only the tiles these dabs touched from dst back to src, so + // both ping-pong textures stay identical and authoritative — only the bytes + // that actually changed are moved (no full-canvas copy). The copies share + // the compute encoder, so wgpu orders them after the dispatch writes. + for (rx, ry, rw, rh) in dirty_tile_rects(dabs, canvas_w, canvas_h) { + compute_enc.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: canvas.dst(), + mip_level: 0, + origin: wgpu::Origin3d { x: rx, y: ry, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: canvas.src(), + mip_level: 0, + origin: wgpu::Origin3d { x: rx, y: ry, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { width: rw, height: rh, depth_or_array_layers: 1 }, + ); + } queue.submit(Some(compute_enc.finish())); // Step 4: Swap once — dst (with all dabs applied) becomes the new src. canvas.swap(); } - /// Read the current canvas back to a CPU `Vec` (raw RGBA, row-major). + /// Read the current canvas back to a CPU `Vec` (sRGB-premultiplied RGBA, + /// row-major). /// - /// **Blocks** until the GPU work is complete (`Maintain::Wait`). - /// Should only be called at stroke end, not every frame. + /// The linear→sRGB conversion runs on the GPU into an `Rgba8Unorm` scratch + /// texture, so the CPU side is just a per-row `memcpy` (no per-pixel decode). + /// **Blocks** until the GPU work is complete. Call at stroke end, not per frame. /// /// Returns `None` if no canvas exists for `keyframe_id`. pub fn readback_canvas( - &self, + &mut self, device: &wgpu::Device, queue: &wgpu::Queue, keyframe_id: Uuid, ) -> Option> { + // Lazily build the conversion pipeline and (re)create the cached scratch if + // the canvas size changed — these mutable borrows end before the shared + // borrows below. + if self.readback_srgb_pipeline.is_none() { + self.readback_srgb_pipeline = Some(ReadbackSrgbPipeline::new(device)); + } + let (width, height) = { + let canvas = self.canvases.get(&keyframe_id)?; + (canvas.width, canvas.height) + }; + if self.readback_scratch.as_ref().map_or(true, |s| s.width != width || s.height != height) { + self.readback_scratch = Some(ReadbackScratch::new(device, width, height)); + } + + let pipeline = self.readback_srgb_pipeline.as_ref().unwrap(); + let scratch = self.readback_scratch.as_ref().unwrap(); let canvas = self.canvases.get(&keyframe_id)?; - let width = canvas.width; - let height = canvas.height; - // wgpu requires bytes_per_row to be a multiple of 256 - let bytes_per_row_aligned = - ((width * 4 + 255) / 256) * 256; - let total_bytes = (bytes_per_row_aligned * height) as u64; - - let staging = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("canvas_readback_buf"), - size: total_bytes, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, + // GPU pass: linear-premultiplied Rgba16Float → sRGB-premultiplied Rgba8Unorm. + // Reading back 8-bit sRGB lets the CPU just memcpy each row. + let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("canvas_readback_srgb_bg"), + layout: &pipeline.bg_layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(canvas.src_view()) }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&scratch.view) }, + ], }); + let bytes_per_row_aligned = scratch.bytes_per_row_aligned; let mut encoder = device.create_command_encoder( &wgpu::CommandEncoderDescriptor { label: Some("canvas_readback_encoder") }, ); + { + let mut pass = encoder.begin_compute_pass( + &wgpu::ComputePassDescriptor { label: Some("canvas_readback_srgb_pass"), timestamp_writes: None }, + ); + pass.set_pipeline(&pipeline.pipeline); + pass.set_bind_group(0, &bg, &[]); + pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1); + } encoder.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { - texture: canvas.src(), + texture: &scratch.tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyBufferInfo { - buffer: &staging, + buffer: &scratch.staging, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(bytes_per_row_aligned), @@ -1249,8 +1438,8 @@ impl GpuBrushEngine { ); queue.submit(Some(encoder.finish())); - // Block until complete - let slice = staging.slice(..); + // Block until complete. + let slice = scratch.staging.slice(..); let (tx, rx) = std::sync::mpsc::channel(); slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); let _ = device.poll(wgpu::PollType::wait_indefinitely()); @@ -1258,27 +1447,19 @@ impl GpuBrushEngine { let mapped = slice.get_mapped_range(); - // De-stride: copy only `width * 4` bytes per row (drop alignment padding) - let bytes_per_row_tight = (width * 4) as usize; - let bytes_per_row_src = bytes_per_row_aligned as usize; + // De-stride with a per-row memcpy (dropping the 256-byte row padding). The + // bytes are already sRGB-premultiplied RGBA8 from the GPU pass, which is + // what Vello expects (ImageAlphaType::Premultiplied with sRGB channels). + let row_tight = (width * 4) as usize; + let row_src = bytes_per_row_aligned as usize; let mut pixels = vec![0u8; (width * height * 4) as usize]; for row in 0..height as usize { - let src = &mapped[row * bytes_per_row_src .. row * bytes_per_row_src + bytes_per_row_tight]; - let dst = &mut pixels[row * bytes_per_row_tight .. (row + 1) * bytes_per_row_tight]; - dst.copy_from_slice(src); + let src = &mapped[row * row_src .. row * row_src + row_tight]; + pixels[row * row_tight .. (row + 1) * row_tight].copy_from_slice(src); } drop(mapped); - staging.unmap(); - - // Encode linear premultiplied → sRGB-encoded premultiplied so the returned - // bytes match what Vello expects (ImageAlphaType::Premultiplied with sRGB - // channels). Alpha is left unchanged. - for pixel in pixels.chunks_exact_mut(4) { - pixel[0] = linear_to_srgb_byte(pixel[0]); - pixel[1] = linear_to_srgb_byte(pixel[1]); - pixel[2] = linear_to_srgb_byte(pixel[2]); - } + scratch.staging.unmap(); Some(pixels) } @@ -1376,6 +1557,33 @@ impl GpuBrushEngine { } self.raster_layer_cache.insert(kf_id, canvas); } + // Bump recency (the frame was used this render) and evict the least-recently + // used textures over budget. The current frame is pushed to the back, so it + // (and every other frame rendered this pass) is never the eviction victim. + if let Some(pos) = self.raster_layer_lru.iter().position(|id| *id == kf_id) { + self.raster_layer_lru.remove(pos); + } + self.raster_layer_lru.push(kf_id); + let mut evicted = false; + while self.raster_layer_lru.len() > Self::RASTER_LAYER_CACHE_MAX { + let old = self.raster_layer_lru.remove(0); + self.raster_layer_cache.remove(&old); + evicted = true; + } + if needs_new || evicted { + self.report_raster_cache_vram(); + } + } + + /// Estimated VRAM footprint of `raster_layer_cache` (two `Rgba16Float` textures = + /// `w·h·16` bytes per entry), published to the F3 debug overlay. + fn report_raster_cache_vram(&self) { + let bytes = |cache: &HashMap| -> usize { + cache.values().map(|c| (c.width as usize) * (c.height as usize) * 16).sum() + }; + let total = bytes(&self.raster_layer_cache) + bytes(&self.proxy_layer_cache); + let count = self.raster_layer_cache.len() + self.proxy_layer_cache.len(); + crate::debug_overlay::update_gpu_memory(count, total); } /// Get the cached display texture for a raster layer keyframe. @@ -1383,9 +1591,55 @@ impl GpuBrushEngine { self.raster_layer_cache.get(kf_id) } + /// Ensure a low-res proxy texture exists for `kf_id` (uploaded once; proxies are + /// immutable). Bumps recency and evicts the least-recently-used past the budget. + /// `pixels` is sRGB-premultiplied RGBA of length `w * h * 4`. + pub fn ensure_proxy_texture( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + kf_id: Uuid, + pixels: &[u8], + w: u32, + h: u32, + ) { + if pixels.len() != (w * h * 4) as usize { + return; // malformed proxy — skip rather than panic in the render loop + } + let mut changed = false; + if !self.proxy_layer_cache.contains_key(&kf_id) { + let canvas = CanvasPair::new(device, w, h); + canvas.upload(queue, pixels); + self.proxy_layer_cache.insert(kf_id, canvas); + changed = true; + } + if let Some(pos) = self.proxy_layer_lru.iter().position(|id| *id == kf_id) { + self.proxy_layer_lru.remove(pos); + } + self.proxy_layer_lru.push(kf_id); + while self.proxy_layer_lru.len() > Self::RASTER_PROXY_CACHE_MAX { + let old = self.proxy_layer_lru.remove(0); + self.proxy_layer_cache.remove(&old); + changed = true; + } + if changed { + self.report_raster_cache_vram(); + } + } + + /// Get the cached low-res proxy texture for a raster keyframe. + pub fn get_proxy_texture(&self, kf_id: &Uuid) -> Option<&CanvasPair> { + self.proxy_layer_cache.get(kf_id) + } + /// Remove the cached texture for a raster layer keyframe (e.g. when deleted). pub fn remove_layer_texture(&mut self, kf_id: &Uuid) { - self.raster_layer_cache.remove(kf_id); + if self.raster_layer_cache.remove(kf_id).is_some() { + if let Some(pos) = self.raster_layer_lru.iter().position(|id| id == kf_id) { + self.raster_layer_lru.remove(pos); + } + self.report_raster_cache_vram(); + } } /// Composite the accumulated-dab scratch buffer C over the source A, writing the @@ -1716,6 +1970,9 @@ pub struct CanvasBlitPipeline { pub pipeline: wgpu::RenderPipeline, pub bg_layout: wgpu::BindGroupLayout, pub sampler: wgpu::Sampler, + /// Bilinear sampler for smooth upscaling (used by `blit_smooth`, e.g. low-res + /// proxies). The default `sampler` stays nearest to keep the real canvas crisp. + pub linear_sampler: wgpu::Sampler, /// Nearest-neighbour sampler used for the selection mask texture. pub mask_sampler: wgpu::Sampler, } @@ -1772,12 +2029,24 @@ impl BlitTransform { // y' = b*x + d*y + f // Column-major 3×3: col0=(a,b,0), col1=(c,d,0), col2=(e,f,1) let [a, b, c, d, e, f] = combined.as_coeffs(); + // The .w slots are unused by the 3×3 matrix; the shader reads them as an RGB + // screen-blend tint ((0,0,0) = no tint — the default for all normal blits). Self { col0: [a as f32, b as f32, 0.0, 0.0], col1: [c as f32, d as f32, 0.0, 0.0], col2: [e as f32, f as f32, 1.0, 0.0], } } + + /// Screen-blend the sampled color toward an RGB tint (for onion-skin ghosts), so + /// blacks/outlines pick up the tint. Packed into the matrix uniform's `.w` slots, + /// so no extra binding is needed. + pub fn with_tint(mut self, r: f32, g: f32, b: f32) -> Self { + self.col0[3] = r; + self.col1[3] = g; + self.col2[3] = b; + self + } } impl CanvasBlitPipeline { @@ -1891,6 +2160,17 @@ impl CanvasBlitPipeline { ..Default::default() }); + let linear_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("canvas_blit_linear_sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + let mask_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("canvas_mask_sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, @@ -1902,7 +2182,7 @@ impl CanvasBlitPipeline { ..Default::default() }); - Self { pipeline, bg_layout, sampler, mask_sampler } + Self { pipeline, bg_layout, sampler, linear_sampler, mask_sampler } } /// Render the canvas texture into `target_view` (Rgba16Float) with the given camera. @@ -1910,6 +2190,7 @@ impl CanvasBlitPipeline { /// `target_view` is cleared to transparent before writing. /// `mask_view` is an R8Unorm texture in canvas-pixel space: 255 = keep, 0 = discard. /// Pass `None` to use the built-in 1×1 all-white default (no masking). + /// Blit with the default nearest-neighbour sampler (crisp; for real canvas pixels). pub fn blit( &self, device: &wgpu::Device, @@ -1918,6 +2199,32 @@ impl CanvasBlitPipeline { target_view: &wgpu::TextureView, transform: &BlitTransform, mask_view: Option<&wgpu::TextureView>, + ) { + self.blit_with(device, queue, canvas_view, target_view, transform, mask_view, &self.sampler); + } + + /// Blit with a bilinear sampler — smooth upscaling for low-res sources (proxies). + pub fn blit_smooth( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + canvas_view: &wgpu::TextureView, + target_view: &wgpu::TextureView, + transform: &BlitTransform, + mask_view: Option<&wgpu::TextureView>, + ) { + self.blit_with(device, queue, canvas_view, target_view, transform, mask_view, &self.linear_sampler); + } + + fn blit_with( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + canvas_view: &wgpu::TextureView, + target_view: &wgpu::TextureView, + transform: &BlitTransform, + mask_view: Option<&wgpu::TextureView>, + canvas_sampler: &wgpu::Sampler, ) { // When no mask is provided, create a temporary 1×1 all-white texture. // (queue is already available here, unlike in new()) @@ -1970,7 +2277,7 @@ impl CanvasBlitPipeline { }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::Sampler(&self.sampler), + resource: wgpu::BindingResource::Sampler(canvas_sampler), }, wgpu::BindGroupEntry { binding: 2, diff --git a/lightningbeam-ui/lightningbeam-editor/src/keymap.rs b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs index 29791c4..62d7cb4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/keymap.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/keymap.rs @@ -103,6 +103,7 @@ pub enum AppAction { TogglePlayPause, CancelAction, ToggleDebugOverlay, + ToggleOnionSkin, #[cfg(debug_assertions)] ToggleTestMode, @@ -150,7 +151,7 @@ impl AppAction { Self::ToolErase | Self::ToolSmudge | Self::ToolSelectLasso | Self::ToolSplit => "Tools", Self::TogglePlayPause | Self::CancelAction | - Self::ToggleDebugOverlay => "Global", + Self::ToggleDebugOverlay | Self::ToggleOnionSkin => "Global", #[cfg(debug_assertions)] Self::ToggleTestMode => "Global", @@ -246,6 +247,7 @@ impl AppAction { Self::TogglePlayPause => "Toggle Play/Pause", Self::CancelAction => "Cancel / Escape", Self::ToggleDebugOverlay => "Toggle Debug Overlay", + Self::ToggleOnionSkin => "Toggle Onion Skinning", #[cfg(debug_assertions)] Self::ToggleTestMode => "Toggle Test Mode", Self::PianoRollDelete => "Piano Roll: Delete", @@ -281,7 +283,7 @@ impl AppAction { Self::ToolEyedropper, Self::ToolLine, Self::ToolPolygon, Self::ToolBezierEdit, Self::ToolText, Self::ToolRegionSelect, Self::ToolErase, Self::ToolSmudge, Self::ToolSelectLasso, Self::ToolSplit, - Self::TogglePlayPause, Self::CancelAction, Self::ToggleDebugOverlay, + Self::TogglePlayPause, Self::CancelAction, Self::ToggleDebugOverlay, Self::ToggleOnionSkin, #[cfg(debug_assertions)] Self::ToggleTestMode, Self::PianoRollDelete, Self::StageDelete, @@ -330,6 +332,7 @@ impl From for AppAction { MenuAction::AddTestClip => Self::AddTestClip, MenuAction::DeleteLayer => Self::DeleteLayer, MenuAction::ToggleLayerVisibility => Self::ToggleLayerVisibility, + MenuAction::ToggleOnionSkin => Self::ToggleOnionSkin, MenuAction::ShowMasterTrack => Self::ToggleLayerVisibility, // not directly mappable MenuAction::NewKeyframe => Self::NewKeyframe, MenuAction::NewBlankKeyframe => Self::NewBlankKeyframe, @@ -392,6 +395,7 @@ impl TryFrom for MenuAction { AppAction::AddTestClip => MenuAction::AddTestClip, AppAction::DeleteLayer => MenuAction::DeleteLayer, AppAction::ToggleLayerVisibility => MenuAction::ToggleLayerVisibility, + AppAction::ToggleOnionSkin => MenuAction::ToggleOnionSkin, AppAction::NewKeyframe => MenuAction::NewKeyframe, AppAction::NewBlankKeyframe => MenuAction::NewBlankKeyframe, AppAction::DeleteFrame => MenuAction::DeleteFrame, @@ -507,6 +511,7 @@ pub fn all_defaults() -> HashMap> { defaults.insert(AppAction::TogglePlayPause, Some(Shortcut::new(ShortcutKey::Space, nc, ns, na))); defaults.insert(AppAction::CancelAction, Some(Shortcut::new(ShortcutKey::Escape, nc, ns, na))); defaults.insert(AppAction::ToggleDebugOverlay, Some(Shortcut::new(ShortcutKey::F3, nc, ns, na))); + defaults.insert(AppAction::ToggleOnionSkin, Some(Shortcut::new(ShortcutKey::O, nc, ns, na))); #[cfg(debug_assertions)] defaults.insert(AppAction::ToggleTestMode, Some(Shortcut::new(ShortcutKey::F5, nc, ns, na))); diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index a902d2a..6ae9f3e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -486,12 +486,96 @@ impl FocusIconCache { } } +/// Serialize a clip's thumbnails to an `LBTN` blob (version 2): a `complete` flag +/// then each frame PNG-encoded and packed with its timestamp. Thumbnails are 128px +/// wide; height is implied by the PNG. `complete` records whether the generating +/// pass finished — a partial pack is persisted and resumed on load. Frames that +/// fail to encode are skipped (the count reflects what's written). +fn encode_thumbnail_blob(thumbs: &[(f64, std::sync::Arc>)], complete: bool) -> Vec { + let mut entries: Vec<(f64, Vec)> = Vec::with_capacity(thumbs.len()); + for (ts, rgba) in thumbs { + let width = 128u32; + let height = (rgba.len() / (width as usize * 4)) as u32; + if height == 0 { + continue; + } + let img = lightningbeam_core::brush_engine::image_from_raw((**rgba).clone(), width, height); + if let Ok(png) = lightningbeam_core::brush_engine::encode_png(&img) { + entries.push((*ts, png)); + } + } + + let mut out = Vec::new(); + out.extend_from_slice(b"LBTN"); + out.extend_from_slice(&2u32.to_le_bytes()); // version + out.push(if complete { 1 } else { 0 }); + out.extend_from_slice(&(entries.len() as u32).to_le_bytes()); + for (ts, png) in &entries { + out.extend_from_slice(&ts.to_le_bytes()); + out.extend_from_slice(&(png.len() as u32).to_le_bytes()); + out.extend_from_slice(png); + } + out +} + +/// Read just the `complete` flag from an `LBTN` blob (cheap header read, no PNG +/// decode). Unknown/old versions are treated as incomplete (→ regenerate). +fn thumbnail_blob_is_complete(blob: &[u8]) -> bool { + blob.len() >= 9 + && &blob[0..4] == b"LBTN" + && u32::from_le_bytes(blob[4..8].try_into().unwrap()) == 2 + && blob[8] == 1 +} + +/// Decode an `LBTN` blob into `(timestamp, rgba)` thumbnails (128px wide). Returns +/// empty for unknown/old versions. +fn decode_thumbnail_blob(blob: &[u8]) -> Vec<(f64, std::sync::Arc>)> { + let mut out = Vec::new(); + if blob.len() < 13 || &blob[0..4] != b"LBTN" { + return out; + } + if u32::from_le_bytes(blob[4..8].try_into().unwrap()) != 2 { + return out; + } + // blob[8] = complete flag (read separately via thumbnail_blob_is_complete) + let count = u32::from_le_bytes(blob[9..13].try_into().unwrap()) as usize; + let mut pos = 13; + for _ in 0..count { + if pos + 12 > blob.len() { + break; + } + let ts = f64::from_le_bytes(blob[pos..pos + 8].try_into().unwrap()); + pos += 8; + let png_len = u32::from_le_bytes(blob[pos..pos + 4].try_into().unwrap()) as usize; + pos += 4; + if pos + png_len > blob.len() { + break; + } + if let Ok(img) = lightningbeam_core::brush_engine::decode_png(&blob[pos..pos + png_len]) { + out.push((ts, std::sync::Arc::new(img.into_raw()))); + } + pos += png_len; + } + out +} + /// Command sent to file operations worker thread enum FileCommand { Save { path: std::path::PathBuf, document: lightningbeam_core::document::Document, layer_to_track_map: std::collections::HashMap, + /// How to store large media on this save (from the user's preference). + large_media_mode: lightningbeam_core::file_io::LargeMediaMode, + /// Serialized waveform-pyramid blobs per audio pool index, persisted into + /// the container so a later load needn't re-decode the source media. + waveform_blobs: std::collections::HashMap>, + /// Raw video thumbnails by clip id (cheap Arc-clone snapshot). The worker + /// PNG-encodes them off the UI thread, then persists them in the container. + thumbnail_snapshot: std::collections::HashMap>)>>, + /// Clips whose thumbnail generation has finished — partial sets are still + /// persisted (and resumed on load), but flagged incomplete. + complete_thumbnail_clips: std::collections::HashSet, progress_tx: std::sync::mpsc::Sender, }, Load { @@ -566,8 +650,8 @@ impl FileOperationsWorker { fn run(self) { while let Ok(command) = self.command_rx.recv() { match command { - FileCommand::Save { path, document, layer_to_track_map, progress_tx } => { - self.handle_save(path, document, &layer_to_track_map, progress_tx); + FileCommand::Save { path, document, layer_to_track_map, large_media_mode, waveform_blobs, thumbnail_snapshot, complete_thumbnail_clips, progress_tx } => { + self.handle_save(path, document, &layer_to_track_map, large_media_mode, waveform_blobs, thumbnail_snapshot, complete_thumbnail_clips, progress_tx); } FileCommand::Load { path, progress_tx } => { self.handle_load(path, progress_tx); @@ -582,10 +666,25 @@ impl FileOperationsWorker { path: std::path::PathBuf, document: lightningbeam_core::document::Document, layer_to_track_map: &std::collections::HashMap, + large_media_mode: lightningbeam_core::file_io::LargeMediaMode, + waveform_blobs: std::collections::HashMap>, + thumbnail_snapshot: std::collections::HashMap>)>>, + complete_thumbnail_clips: std::collections::HashSet, progress_tx: std::sync::mpsc::Sender, ) { use lightningbeam_core::file_io::{save_beam, SaveSettings}; + // PNG-encode the thumbnail snapshot into per-clip LBTN blobs (off the UI + // thread), flagging each pack complete-or-partial so the load can resume. + let thumbnail_blobs: std::collections::HashMap> = thumbnail_snapshot + .iter() + .filter(|(_, thumbs)| !thumbs.is_empty()) + .map(|(clip_id, thumbs)| { + let complete = complete_thumbnail_clips.contains(clip_id); + (*clip_id, encode_thumbnail_blob(thumbs, complete)) + }) + .collect(); + let save_start = std::time::Instant::now(); eprintln!("📊 [SAVE] Starting save operation..."); @@ -593,7 +692,7 @@ impl FileOperationsWorker { let _ = progress_tx.send(FileProgress::SerializingAudioPool); let step1_start = std::time::Instant::now(); - let audio_pool_entries = { + let mut audio_pool_entries = { let mut controller = self.audio_controller.lock().unwrap(); match controller.serialize_audio_pool(&path) { Ok(entries) => entries, @@ -603,6 +702,13 @@ impl FileOperationsWorker { } } }; + // Attach precomputed waveform pyramids so save_beam can persist them + // (keyed by pool index inside the container). + for entry in &mut audio_pool_entries { + if let Some(blob) = waveform_blobs.get(&entry.pool_index) { + entry.waveform_blob = Some(blob.clone()); + } + } eprintln!("📊 [SAVE] Step 1: Serialize audio pool took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0); // Step 2: Get project @@ -623,8 +729,11 @@ impl FileOperationsWorker { let _ = progress_tx.send(FileProgress::WritingZip); let step3_start = std::time::Instant::now(); - let settings = SaveSettings::default(); - match save_beam(&path, &document, &mut audio_project, audio_pool_entries, layer_to_track_map, &settings) { + let settings = SaveSettings { + large_media_mode, + ..SaveSettings::default() + }; + match save_beam(&path, &document, &mut audio_project, audio_pool_entries, layer_to_track_map, &thumbnail_blobs, &settings) { Ok(()) => { eprintln!("📊 [SAVE] Step 3: save_beam() took {:.2}ms", step3_start.elapsed().as_secs_f64() * 1000.0); eprintln!("📊 [SAVE] ✅ Total save time: {:.2}ms", save_start.elapsed().as_secs_f64() * 1000.0); @@ -687,9 +796,6 @@ enum AudioExtractionResult { channels: u32, sample_rate: u32, }, - NoAudio { - video_clip_id: Uuid, - }, Error { video_clip_id: Uuid, error: String, @@ -878,8 +984,9 @@ struct EditorApp { snap_enabled: bool, // Whether to snap to geometry (default: true) paint_bucket_gap_tolerance: f64, // Fill gap tolerance for paint bucket (default: 5.0) polygon_sides: u32, // Number of sides for polygon tool (default: 5) - // Region select state - region_selection: Option, + /// Undo depth before the current region-select session's first cut (see + /// `resolve_pending_region_cut`). `None` when no region selection is pending. + pending_region_cut_base: Option, region_select_mode: lightningbeam_core::tool::RegionSelectMode, lasso_mode: lightningbeam_core::tool::LassoMode, @@ -901,10 +1008,45 @@ struct EditorApp { raw_audio_cache: HashMap>, u32, u32)>, /// Pool indices that need GPU texture upload (set when raw audio arrives, cleared after upload) waveform_gpu_dirty: HashSet, + /// Pool indices whose `raw_audio_cache` entry holds a packed min/max floor + /// (4 f32 per texel: Lmin,Lmax,Rmin,Rmax) rather than raw per-sample data. + /// Maps pool_index -> `B` (floor frames-per-texel), so the timeline render can + /// derive the floor's effective rate `sr/B` with full float precision. Populated + /// for streamed video-audio, which has no in-RAM samples to draw per-sample. + waveform_minmax_pools: HashMap, + /// Serialized waveform-pyramid blobs (LBWF bytes) per pool, kept so a save can + /// persist them into the `.beam` container without re-decoding. Populated on + /// generation and on load. See `daw_backend::audio::waveform_pyramid::to_bytes`. + waveform_pyramid_blobs: HashMap>>, + /// Receives generated waveforms from a background thread: + /// (pool_index, packed_floor_texels, source_sample_rate, channels, B, pyramid_blob). + waveform_result_rx: std::sync::mpsc::Receiver<(usize, Vec, u32, u32, u32, Vec)>, + /// Sender handed to background waveform-pyramid generation threads. + waveform_result_tx: std::sync::mpsc::Sender<(usize, Vec, u32, u32, u32, Vec)>, /// Consumer for recording audio mirror (streams recorded samples to UI for live waveform) recording_mirror_rx: Option>, /// Current file path (None if not yet saved) current_file_path: Option, + /// Onion-skinning view settings (toggle + frame counts + opacity). + onion_skin: crate::panes::OnionSkinSettings, + /// On-demand loader for raster keyframe pixels from the project `.beam` container. + raster_store: lightningbeam_core::raster_store::RasterStore, + /// Miss-sink: raster keyframe ids the canvas wanted but whose pixels weren't + /// resident. Drained and faulted in at the top of the next `update()`. + raster_fault_requests: + std::sync::Arc>>, + /// Async raster page-in: background decode results `(kf_id, pixels-or-None)`. + /// `None` = the keyframe genuinely isn't in the container (blank). + raster_load_result_tx: std::sync::mpsc::Sender<(uuid::Uuid, Option>)>, + raster_load_result_rx: std::sync::mpsc::Receiver<(uuid::Uuid, Option>)>, + /// Keyframe ids whose page-in is currently running on a background thread + /// (prevents duplicate loads while the canvas keeps re-requesting them). + raster_loads_inflight: std::collections::HashSet, + /// Fault-in-recency LRU of resident raster keyframe ids. Most-recently + /// paged-in is at the back; eviction pops the front when over budget + /// (`RASTER_RESIDENT_MAX`). The currently-shown frame is always the most + /// recent fault-in, so it's never evicted. + raster_resident_lru: std::collections::VecDeque, /// Application configuration (recent files, etc.) config: AppConfig, /// Remappable keyboard shortcut manager @@ -923,10 +1065,19 @@ struct EditorApp { export_dialog: export::dialog::ExportDialog, /// Export progress dialog export_progress_dialog: export::dialog::ExportProgressDialog, + /// When `Some(name)`, show the "large media: pack or reference?" prompt for the + /// named just-imported file. Set on the first large import while the + /// `large_media_default` preference is still `Ask`. + large_media_prompt: Option, /// Preferences dialog preferences_dialog: preferences::dialog::PreferencesDialog, /// Export orchestrator for background exports export_orchestrator: Option, + /// Vello renderer + image cache reused across all frames of an in-progress export + /// (built once on the first export pump, dropped when export ends) — building a new + /// renderer per frame was the dominant export cost. + export_renderer: Option, + export_image_cache: Option, /// GPU-rendered effect thumbnail generator effect_thumbnail_generator: Option, @@ -962,6 +1113,11 @@ enum ImportFilter { } impl EditorApp { + /// Maximum number of raster keyframes whose `raw_pixels` stay resident in + /// RAM. Older clean keyframes beyond this are evicted (re-page on revisit); + /// the most-recently-shown frame is always protected. + const RASTER_RESIDENT_MAX: usize = 12; + fn new( cc: &eframe::CreationContext, layouts: Vec, @@ -1069,6 +1225,9 @@ impl EditorApp { .map(|rs| rs.target_format) .unwrap_or(wgpu::TextureFormat::Rgba8Unorm); + let (waveform_result_tx, waveform_result_rx) = std::sync::mpsc::channel(); + let (raster_load_result_tx, raster_load_result_rx) = std::sync::mpsc::channel(); + Self { layouts, current_layout_index: 0, @@ -1145,7 +1304,7 @@ impl EditorApp { snap_enabled: true, // Default to snapping paint_bucket_gap_tolerance: 5.0, // Default gap tolerance polygon_sides: 5, // Default to pentagon - region_selection: None, + pending_region_cut_base: None, region_select_mode: lightningbeam_core::tool::RegionSelectMode::default(), lasso_mode: lightningbeam_core::tool::LassoMode::default(), input_level: 0.0, @@ -1156,8 +1315,19 @@ impl EditorApp { audio_pools_with_new_waveforms: HashSet::new(), // Track pool indices with new raw audio raw_audio_cache: HashMap::new(), waveform_gpu_dirty: HashSet::new(), + waveform_minmax_pools: HashMap::new(), + waveform_pyramid_blobs: HashMap::new(), + waveform_result_rx, + waveform_result_tx, recording_mirror_rx, current_file_path: None, // No file loaded initially + onion_skin: crate::panes::OnionSkinSettings::default(), + raster_store: lightningbeam_core::raster_store::RasterStore::new(None), + raster_fault_requests: Default::default(), + raster_load_result_tx, + raster_load_result_rx, + raster_loads_inflight: Default::default(), + raster_resident_lru: Default::default(), keymap: KeymapManager::new(&config.keybindings), config, file_command_tx, @@ -1166,8 +1336,11 @@ impl EditorApp { audio_extraction_rx, export_dialog: export::dialog::ExportDialog::default(), export_progress_dialog: export::dialog::ExportProgressDialog::default(), + large_media_prompt: None, preferences_dialog: preferences::dialog::PreferencesDialog::default(), export_orchestrator: None, + export_renderer: None, + export_image_cache: None, effect_thumbnail_generator: None, // Initialized when GPU available // Debug test mode (F5) @@ -2162,6 +2335,39 @@ impl EditorApp { /// Cancel a floating raster selection: restore the canvas from the /// pre-cut/paste snapshot. No undo entry is created. + /// Fault in the raster keyframe that a pending undo/redo needs resident, so its + /// dirty-rect diff applies onto the correct full base. A clean evicted frame's + /// container bytes equal its current logical state, so this restores the right + /// base. Synchronous — undo/redo is a discrete user action, not a hot path. + fn ensure_raster_resident_for_undo(&mut self, hint: Option<(uuid::Uuid, f64)>) { + use lightningbeam_core::layer::AnyLayer; + let Some((layer_id, time)) = hint else { return }; + if !self.raster_store.has_path() { return; } + // Identify the (paged-out) keyframe the action will touch. + let target = { + let doc = self.action_executor.document(); + match doc.get_layer(&layer_id) { + Some(AnyLayer::Raster(rl)) => rl + .keyframe_at(time) + .filter(|kf| kf.raw_pixels.is_empty() && kf.needs_fault_in) + .map(|kf| kf.id), + _ => None, + } + }; + if let Some(kf_id) = target { + if let Some(pixels) = self.raster_store.load_pixels(kf_id) { + let doc = self.action_executor.document_mut(); + if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&layer_id) { + if let Some(kf) = rl.keyframes.iter_mut().find(|k| k.id == kf_id) { + kf.raw_pixels = pixels; + kf.texture_dirty = true; + kf.needs_fault_in = false; + } + } + } + } + } + fn cancel_raster_floating(&mut self) { use lightningbeam_core::layer::AnyLayer; @@ -2172,6 +2378,11 @@ impl EditorApp { let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return }; let Some(kf) = rl.keyframe_at_mut(float.time) else { return }; kf.raw_pixels = std::sync::Arc::try_unwrap(float.canvas_before).unwrap_or_else(|a| (*a).clone()); + // Restoring the pre-lift snapshot rewrites raw_pixels in memory; that + // snapshot may itself contain un-persisted edits, and the lift had marked + // the kf dirty. Keep it pinned (dirty) so eviction can't lose it. + kf.dirty = true; + kf.texture_dirty = true; } /// Drop (discard) the floating selection keeping the hole punched in the @@ -2419,27 +2630,7 @@ impl EditorApp { None => return, }; - // Region selection case: faces are selected but no edges. - // The inside geometry was already extracted from the live DCEL; - // commit the current state (outside + boundary) using the - // pre-boundary snapshot as the "before" for undo. - if self.selection.selected_edges().is_empty() { - if let Some(region_sel) = self.region_selection.take() { - // dcel_snapshot = state before boundary was inserted. - // Current document DCEL = outside portion only (boundary edges present). - // We commit the snapshot as "before" and the current state as "after", - // then drop the region selection so it is not merged back. - // TODO: Region selection delete requires converting Dcel snapshot - // to VectorGraph for ModifyGraphAction. Deferred until RegionSelection - // is migrated from Dcel to VectorGraph. - eprintln!("Region selection delete: not yet ported to VectorGraph"); - // region_sel is dropped; the stage pane will see region_selection == None. - } - self.selection.clear_geometry_selection(); - return; - } - - // Select-tool case: delete the selected edges. + // Delete the selected edges (region/marquee/click all populate the same sets). let edge_ids: Vec = self.selection.selected_edges().iter().copied().collect(); @@ -2826,38 +3017,31 @@ impl EditorApp { } } - /// Revert an uncommitted region selection, restoring original shapes - fn revert_region_selection( - region_selection: &mut Option, - action_executor: &mut lightningbeam_core::action::ActionExecutor, - selection: &mut lightningbeam_core::selection::Selection, - ) { - use lightningbeam_core::layer::AnyLayer; - - let region_sel = match region_selection.take() { - Some(rs) => rs, - None => return, - }; - - if region_sel.committed { + /// Resolve a pending region-select cut once its selection has been cleared. + /// + /// A region/lasso selection cuts the geometry (an undoable `"Region select"` action) + /// and selects the resulting sub-pieces. When that selection is deselected: + /// - if nothing was edited in between, the cut(s) are healed (undone) so the lasso + /// was non-destructive — the shape returns to whole; + /// - if anything was edited, everything is kept (the edits — and the cut they rely + /// on — stay on the undo stack), i.e. the change is "merged" in place. + /// We tell the two apart by walking the undo stack down to the recorded base depth and + /// only undoing while the top action is itself a region-select cut; the first non-cut + /// action (an edit) stops the heal and leaves everything intact. + fn resolve_pending_region_cut(&mut self) { + let Some(base) = self.pending_region_cut_base else { return }; + // Only resolve once the region selection has actually been deselected. + if self.selection.has_geometry_selection() { return; } - - let doc = action_executor.document_mut(); - let layer = match doc.get_layer_mut(®ion_sel.layer_id) { - Some(l) => l, - None => return, - }; - let vector_layer = match layer { - AnyLayer::Vector(vl) => vl, - _ => return, - }; - - // TODO: DCEL - region selection revert disabled during migration - // (was: remove/add_shape_from/to_keyframe for splits) - let _ = vector_layer; - - selection.clear(); + while self.action_executor.undo_depth() > base { + if self.action_executor.undo_description().as_deref() == Some("Region select") { + let _ = self.action_executor.undo(); + } else { + break; // an edit sits on top → the user changed something; keep it all. + } + } + self.pending_region_cut_base = None; } fn handle_menu_action(&mut self, action: MenuAction) { @@ -2882,6 +3066,8 @@ impl EditorApp { self.audio_duration_cache.clear(); self.raw_audio_cache.clear(); self.waveform_gpu_dirty.clear(); + self.waveform_minmax_pools.clear(); + self.waveform_pyramid_blobs.clear(); self.pane_instances.clear(); self.project_generation += 1; self.app_mode = AppMode::StartScreen; @@ -3113,6 +3299,10 @@ impl EditorApp { self.cancel_raster_floating(); return; } + // Page in the target raster frame first so its dirty-rect diff has a + // resident base to apply onto. + let hint = self.action_executor.peek_undo_raster_hint(); + self.ensure_raster_resident_for_undo(hint); let undo_succeeded = if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); let mut backend_context = lightningbeam_core::action::BackendContext { @@ -3162,6 +3352,8 @@ impl EditorApp { } } MenuAction::Redo => { + let hint = self.action_executor.peek_redo_raster_hint(); + self.ensure_raster_resident_for_undo(hint); let redo_succeeded = if let Some(ref controller_arc) = self.audio_controller { let mut controller = controller_arc.lock().unwrap(); let mut backend_context = lightningbeam_core::action::BackendContext { @@ -3253,26 +3445,24 @@ impl EditorApp { self.pending_node_group = true; } _ => { - // Existing clip instance grouping fallback (stub) + // Group selected geometry into a group clip + clip instance. if let Some(layer_id) = self.active_layer_id { if self.selection.has_geometry_selection() { - // TODO: DCEL group deferred to Phase 2 - } else { - let clip_ids: Vec = self.selection.clip_instances().to_vec(); - if clip_ids.len() >= 2 { - let instance_id = uuid::Uuid::new_v4(); - let action = lightningbeam_core::actions::GroupAction::new( - layer_id, self.playback_time, Vec::new(), clip_ids, instance_id, - ); - if let Err(e) = self.action_executor.execute(Box::new(action)) { - eprintln!("Failed to group: {}", e); - } else { - self.selection.clear(); - self.selection.add_clip_instance(instance_id); - } + let fills: Vec<_> = self.selection.selected_fills().iter().copied().collect(); + let edges: Vec<_> = self.selection.selected_edges().iter().copied().collect(); + let clip_id = uuid::Uuid::new_v4(); + let instance_id = uuid::Uuid::new_v4(); + let action = lightningbeam_core::actions::GroupAction::new( + layer_id, self.playback_time, fills, edges, clip_id, instance_id, + ); + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Failed to group: {}", e); + } else { + self.selection.clear(); + self.selection.add_clip_instance(instance_id); } } - let _ = layer_id; + // (Grouping existing clip instances is a separate op, not wired.) } } } @@ -3280,24 +3470,18 @@ impl EditorApp { MenuAction::ConvertToMovieClip => { if let Some(layer_id) = self.active_layer_id { if self.selection.has_geometry_selection() { - // TODO: DCEL convert-to-movie-clip deferred to Phase 2 - } else { - let clip_ids: Vec = self.selection.clip_instances().to_vec(); - if clip_ids.len() >= 1 { - let instance_id = uuid::Uuid::new_v4(); - let action = lightningbeam_core::actions::ConvertToMovieClipAction::new( - layer_id, - self.playback_time, - Vec::new(), - clip_ids, - instance_id, - ); - if let Err(e) = self.action_executor.execute(Box::new(action)) { - eprintln!("Failed to convert to movie clip: {}", e); - } else { - self.selection.clear(); - self.selection.add_clip_instance(instance_id); - } + let fills: Vec<_> = self.selection.selected_fills().iter().copied().collect(); + let edges: Vec<_> = self.selection.selected_edges().iter().copied().collect(); + let clip_id = uuid::Uuid::new_v4(); + let instance_id = uuid::Uuid::new_v4(); + let action = lightningbeam_core::actions::ConvertToMovieClipAction::new( + layer_id, self.playback_time, fills, edges, clip_id, instance_id, + ); + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Failed to convert to movie clip: {}", e); + } else { + self.selection.clear(); + self.selection.add_clip_instance(instance_id); } } } @@ -3522,27 +3706,39 @@ impl EditorApp { MenuAction::NewKeyframe | MenuAction::AddKeyframeAtPlayhead => { if let Some(layer_id) = self.active_layer_id { let document = self.action_executor.document(); - // Determine which selected objects are shape instances vs clip instances - let _shape_ids: Vec = Vec::new(); - let mut clip_ids = Vec::new(); - if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) { - // TODO: DCEL - shape instance lookup disabled during migration - // (was: get_shape_in_keyframe to check which selected objects are shapes) - for &id in self.selection.clip_instances() { - if vl.clip_instances.iter().any(|ci| ci.id == id) { - clip_ids.push(id); - } - } - } - // For vector layers, always create a shape keyframe (even without clip selection) - if document.get_layer(&layer_id).map_or(false, |l| matches!(l, AnyLayer::Vector(_))) || !clip_ids.is_empty() { - let action = lightningbeam_core::actions::SetKeyframeAction::new( - layer_id, - self.playback_time, - clip_ids, + let is_raster = matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))); + if is_raster { + // Raster layers: insert a blank cel at the playhead. + let (doc_w, doc_h) = (document.width as u32, document.height as u32); + let action = lightningbeam_core::actions::AddRasterKeyframeAction::new( + layer_id, self.playback_time, doc_w, doc_h, ); if let Err(e) = self.action_executor.execute(Box::new(action)) { - eprintln!("Failed to set keyframe: {}", e); + eprintln!("Failed to add raster keyframe: {}", e); + } + } else { + // Determine which selected objects are shape instances vs clip instances + let _shape_ids: Vec = Vec::new(); + let mut clip_ids = Vec::new(); + if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) { + // TODO: DCEL - shape instance lookup disabled during migration + // (was: get_shape_in_keyframe to check which selected objects are shapes) + for &id in self.selection.clip_instances() { + if vl.clip_instances.iter().any(|ci| ci.id == id) { + clip_ids.push(id); + } + } + } + // For vector layers, always create a shape keyframe (even without clip selection) + if document.get_layer(&layer_id).map_or(false, |l| matches!(l, AnyLayer::Vector(_))) || !clip_ids.is_empty() { + let action = lightningbeam_core::actions::SetKeyframeAction::new( + layer_id, + self.playback_time, + clip_ids, + ); + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Failed to set keyframe: {}", e); + } } } } @@ -3565,8 +3761,16 @@ impl EditorApp { // TODO: Implement add motion tween } MenuAction::AddShapeTween => { - println!("Menu: Add Shape Tween"); - // TODO: Implement add shape tween + if let Some(layer_id) = self.active_layer_id { + let action = lightningbeam_core::actions::SetTweenAction::new( + layer_id, + self.playback_time, + lightningbeam_core::layer::TweenType::Shape, + ); + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Failed to add shape tween: {}", e); + } + } } MenuAction::ReturnToStart => { println!("Menu: Return to Start"); @@ -3596,6 +3800,9 @@ impl EditorApp { MenuAction::RecenterView => { self.pending_view_action = Some(MenuAction::RecenterView); } + MenuAction::ToggleOnionSkin => { + self.onion_skin.enabled = !self.onion_skin.enabled; + } MenuAction::NextLayout => { println!("Menu: Next Layout"); let next_index = (self.current_layout_index + 1) % self.layouts.len(); @@ -3666,11 +3873,31 @@ impl EditorApp { // Clone document for background thread let document = self.action_executor.document().clone(); + // Snapshot the generated waveform pyramids (by pool index) so the worker + // can persist them into the container alongside the audio. + let waveform_blobs: std::collections::HashMap> = self + .waveform_pyramid_blobs + .iter() + .map(|(&idx, blob)| (idx, blob.as_ref().clone())) + .collect(); + + // Snapshot all video thumbnails (cheap Arc-clone) + which clips finished + // generating; the worker PNG-encodes them with a complete/partial flag so a + // save mid-generation persists progress and resumes on load. + let (thumbnail_snapshot, complete_thumbnail_clips) = { + let vm = self.video_manager.lock().unwrap(); + (vm.snapshot_all_thumbnails(), vm.complete_thumbnail_clips()) + }; + // Send save command to worker thread let command = FileCommand::Save { path: path.clone(), document, layer_to_track_map: self.layer_to_track_map.clone(), + large_media_mode: self.config.large_media_default, + waveform_blobs, + thumbnail_snapshot, + complete_thumbnail_clips, progress_tx, }; @@ -3756,7 +3983,7 @@ impl EditorApp { } /// Apply loaded project data (called after successful load in background) - fn apply_loaded_project(&mut self, loaded_project: lightningbeam_core::file_io::LoadedProject, path: std::path::PathBuf) { + fn apply_loaded_project(&mut self, mut loaded_project: lightningbeam_core::file_io::LoadedProject, path: std::path::PathBuf) { use lightningbeam_core::action::ActionExecutor; let apply_start = std::time::Instant::now(); @@ -3781,6 +4008,34 @@ impl EditorApp { self.restore_layout_from_document(); eprintln!("📊 [APPLY] Step 2: Restore UI layout took {:.2}ms", step2_start.elapsed().as_secs_f64() * 1000.0); + // Snapshot persisted waveform pyramids (with each entry's source sample + // rate) before the backend consumes the entries below. Restored into the + // GPU caches after the controller borrow ends, so a load needn't re-decode. + let loaded_waveforms: Vec<(usize, u32, Vec)> = loaded_project + .audio_pool_entries + .iter() + .filter_map(|e| e.waveform_blob.as_ref().map(|b| (e.pool_index, e.sample_rate, b.clone()))) + .collect(); + + // Streamed (packed) audio entries that have NO persisted pyramid yet + // (e.g. saved before waveform persistence): generate one in the background + // from the packed blob so the overview appears without a full decode. + // (pool_index, media_id, codec/ext, sample_rate) + let streamed_needing_waveform: Vec<(usize, String, Option, u32)> = loaded_project + .audio_pool_entries + .iter() + .filter(|e| e.waveform_blob.is_none() && e.embedded_data.is_none()) + .filter_map(|e| { + e.media_id.clone().map(|id| { + let ext = std::path::Path::new(&e.name) + .extension() + .and_then(|x| x.to_str()) + .map(|s| s.to_lowercase()); + (e.pool_index, id, ext, e.sample_rate) + }) + }) + .collect(); + // Load audio pool FIRST (before setting project, so clips can reference pool entries) let step3_start = std::time::Instant::now(); if let Some(ref controller_arc) = self.audio_controller { @@ -3794,6 +4049,15 @@ impl EditorApp { } eprintln!("📊 [APPLY] Step 3: Load audio pool took {:.2}ms", step3_start.elapsed().as_secs_f64() * 1000.0); + // Install the packed-media byte-source factory for this container before + // SetProject, so bulk activation can stream container-packed audio. + if lightningbeam_core::beam_archive::BeamArchive::is_sqlite(&path) { + let factory = lightningbeam_core::file_io::blob_source_factory(&path); + if let Err(e) = controller.set_blob_source_factory(factory) { + eprintln!("⚠️ [APPLY] Failed to install blob source factory: {}", e); + } + } + // Now set project (clips can now reference the loaded pool entries) let step4_start = std::time::Instant::now(); if let Err(e) = controller.set_project(loaded_project.audio_project) { @@ -3810,6 +4074,86 @@ impl EditorApp { ); } + // Restore waveform overviews from the persisted pyramids (no re-decode). + // Clear first so stale pools from the previous project don't linger; + // on-demand audio repopulates raw_audio_cache lazily at render time. + self.raw_audio_cache.clear(); + self.waveform_gpu_dirty.clear(); + self.waveform_minmax_pools.clear(); + self.waveform_pyramid_blobs.clear(); + for (pool_index, sample_rate, blob) in loaded_waveforms { + match daw_backend::audio::waveform_pyramid::WaveformPyramid::from_bytes(&blob) { + Ok(pyramid) => { + let b = pyramid.floor_samples_per_texel.max(1); + let channels = pyramid.channels; + let floor = pyramid.floor(); + let mut packed = Vec::with_capacity(floor.len() * 4); + for t in floor { + packed.push(t.l_min); + packed.push(t.l_max); + packed.push(t.r_min); + packed.push(t.r_max); + } + self.raw_audio_cache.insert(pool_index, (Arc::new(packed), sample_rate, channels)); + self.waveform_minmax_pools.insert(pool_index, b); + self.waveform_pyramid_blobs.insert(pool_index, Arc::new(blob)); + self.waveform_gpu_dirty.insert(pool_index); + } + Err(e) => eprintln!( + "⚠️ [APPLY] Failed to parse persisted waveform for pool {}: {}", + pool_index, e + ), + } + } + + // Background-generate overviews for streamed audio that has no persisted + // pyramid yet (older saves). Streams the packed blob via the factory and + // sends the floor through the same channel `update()` already drains; the + // next save then persists it. + if !streamed_needing_waveform.is_empty() { + let wf_tx = self.waveform_result_tx.clone(); + let floor_b = self.config.waveform_floor_samples_per_texel.max(1); + let beam_path = path.clone(); + std::thread::spawn(move || { + let factory = lightningbeam_core::file_io::blob_source_factory(&beam_path); + for (pool_index, media_id, ext, sample_rate) in streamed_needing_waveform { + let src = match factory.open(&media_id) { + Ok(s) => s, + Err(e) => { + eprintln!("[APPLY] waveform gen: open packed {} failed: {}", media_id, e); + continue; + } + }; + match daw_backend::audio::disk_reader::build_waveform_pyramid_from_source( + src, + ext.as_deref(), + floor_b, + ) { + Ok(pyramid) => { + let floor = pyramid.floor(); + let mut packed = Vec::with_capacity(floor.len() * 4); + for t in floor { + packed.push(t.l_min); + packed.push(t.l_max); + packed.push(t.r_min); + packed.push(t.r_max); + } + let blob = pyramid.to_bytes(); + let _ = wf_tx.send(( + pool_index, + packed, + sample_rate, + pyramid.channels, + pyramid.floor_samples_per_texel, + blob, + )); + } + Err(e) => eprintln!("[APPLY] waveform gen failed for pool {}: {}", pool_index, e), + } + } + }); + } + // Reset state and restore track mappings let step5_start = std::time::Instant::now(); self.layer_to_track_map.clear(); @@ -3890,10 +4234,47 @@ impl EditorApp { } eprintln!("📊 [APPLY] Step 8: Rebuilt MIDI event cache for {} clips in {:.2}ms", midi_fetched, step8_start.elapsed().as_secs_f64() * 1000.0); + // Restore persisted video thumbnails (decode + insert off-thread). Only + // *complete* packs are skipped from regeneration; *partial* packs are + // restored AND have generation resumed below (it skips the keyframes already + // restored), so a save mid-generation continues from where it left off. + let skip_thumbnail_clips: std::collections::HashSet = loaded_project + .thumbnail_blobs + .iter() + .filter(|(_, blob)| thumbnail_blob_is_complete(blob)) + .map(|(id, _)| *id) + .collect(); + if !loaded_project.thumbnail_blobs.is_empty() { + let blobs = std::mem::take(&mut loaded_project.thumbnail_blobs); + let video_manager = Arc::clone(&self.video_manager); + std::thread::spawn(move || { + for (clip_id, blob) in blobs { + let complete = thumbnail_blob_is_complete(&blob); + let thumbs = decode_thumbnail_blob(&blob); + let mut vm = video_manager.lock().unwrap(); + for (ts, rgba) in thumbs { + vm.insert_thumbnail(&clip_id, ts, rgba); + } + if complete { + vm.mark_thumbnails_complete(&clip_id); + } + } + }); + } + + // Re-register video clips with the VideoManager (decoder + keyframe index + + // thumbnails). Restored video_clips carry only metadata + file_path; without + // this they render black with no thumbnails. + let step9_start = std::time::Instant::now(); + self.register_loaded_videos(&skip_thumbnail_clips); + eprintln!("📊 [APPLY] Step 9: Registered loaded videos in {:.2}ms", step9_start.elapsed().as_secs_f64() * 1000.0); + // Reset playback state self.playback_time = 0.0; self.is_playing = false; self.current_file_path = Some(path.clone()); + // Point the raster paging store at the loaded container so faulting works. + self.raster_store.set_path(self.current_file_path.clone()); // Add to recent files self.config.add_recent_file(path.clone()); @@ -3908,9 +4289,191 @@ impl EditorApp { println!("✅ Loaded from: {}", path.display()); } + /// Register every video clip in the loaded document with the `VideoManager` + /// so it can decode and display. Mirrors the import path's setup (decoder + + /// background keyframe index + thumbnails) but does NOT re-extract audio — + /// the extracted audio is already restored via the audio pool. + fn register_loaded_videos(&mut self, skip_thumbnail_clips: &std::collections::HashSet) { + let doc_width = self.action_executor.document().width as u32; + let doc_height = self.action_executor.document().height as u32; + + // Snapshot (id, path) so we don't hold the document borrow while locking + // the VideoManager / spawning threads. + let videos: Vec<_> = self + .action_executor + .document() + .video_clips + .values() + .map(|c| (c.id, c.file_path.clone())) + .collect(); + + for (clip_id, file_path) in videos { + if !std::path::Path::new(&file_path).exists() { + eprintln!("⚠️ [APPLY] Video file not found, skipping: {}", file_path); + continue; + } + + { + let mut video_mgr = self.video_manager.lock().unwrap(); + if let Err(e) = video_mgr.load_video(clip_id, file_path.clone(), doc_width, doc_height) { + eprintln!("⚠️ [APPLY] Failed to load video {}: {}", file_path, e); + continue; + } + } + + self.spawn_keyframe_index(clip_id); + // Skip regeneration for clips whose thumbnails were restored from the + // container — they're being decoded + inserted in the background. + if !skip_thumbnail_clips.contains(&clip_id) { + self.spawn_thumbnail_generation(clip_id); + } + } + } + + /// Spawn a background thread that builds the keyframe (seek) index for a + /// video clip. The clip must already be registered via `load_video`. + /// + /// The slow packet scan runs holding **no** lock — only brief locks bracket it + /// (to grab the decoder handle and to store the result) — so it never blocks + /// playback or the UI (which both need the VideoManager / decoder locks). + fn spawn_keyframe_index(&self, clip_id: uuid::Uuid) { + let video_manager = Arc::clone(&self.video_manager); + std::thread::spawn(move || { + let decoder_arc = { + let vm = video_manager.lock().unwrap(); + vm.get_decoder(&clip_id) + }; + let Some(decoder_arc) = decoder_arc else { return; }; + + let (path, stream_index) = { + let decoder = decoder_arc.lock().unwrap(); + decoder.keyframe_scan_params() + }; + + // Slow scan, no locks held. + match lightningbeam_core::video::VideoDecoder::scan_keyframes(&path, stream_index) { + Ok(positions) => { + let count = positions.len(); + decoder_arc.lock().unwrap().set_keyframe_index(positions); + println!(" Built keyframe index ({} keyframes) for video clip {}", count, clip_id); + } + Err(e) => eprintln!("Failed to build keyframe index for {}: {}", clip_id, e), + } + }); + } + + /// Spawn a background thread that (re)generates timeline thumbnails for a + /// video clip. Uses a **dedicated** decoder (independent of the playback + /// decoder) and samples at keyframes, so it neither blocks playback nor pays + /// the cost of decoding whole GOPs. Safe to call on its own to refresh a + /// single clip's thumbnails. + fn spawn_thumbnail_generation(&self, clip_id: uuid::Uuid) { + let video_manager = Arc::clone(&self.video_manager); + std::thread::spawn(move || { + // Grab the source path from the playback decoder (brief lock), then do + // all decoding on an independent decoder. + let path = { + let decoder_arc = { + let vm = video_manager.lock().unwrap(); + vm.get_decoder(&clip_id) + }; + let Some(decoder_arc) = decoder_arc else { return; }; + let decoder = decoder_arc.lock().unwrap(); + decoder.path().to_string() + }; + + let vm_insert = Arc::clone(&video_manager); + let vm_skip = Arc::clone(&video_manager); + let result = lightningbeam_core::video::generate_keyframe_thumbnails( + &path, + 5.0, + 128, + // Resume: skip keyframes already covered (e.g. restored from a + // partial persisted pack), so generation only fills the gaps. + |ts| vm_skip.lock().unwrap().has_thumbnail_near(&clip_id, ts, 2.5), + |ts, rgba| { + let mut vm = vm_insert.lock().unwrap(); + vm.insert_thumbnail(&clip_id, ts, rgba); + }, + ); + match result { + Ok(()) => { + // Full keyframe pass finished — mark complete so this set is + // worth persisting (a partial set is left unmarked → not saved). + vm_insert.lock().unwrap().mark_thumbnails_complete(&clip_id); + } + Err(e) => eprintln!("Thumbnail generation failed for {}: {}", clip_id, e), + } + }); + } + + /// If `path` is a large media file (≥ the large-media threshold) and the user + /// hasn't yet chosen a default pack-vs-reference policy, queue the one-time + /// prompt. The actual storage decision happens at save time from the chosen + /// preference; this only establishes that preference. + fn note_possible_large_media(&mut self, path: &std::path::Path) { + use lightningbeam_core::file_io::LargeMediaMode; + if self.config.large_media_default != LargeMediaMode::Ask || self.large_media_prompt.is_some() { + return; + } + let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + if size >= lightningbeam_core::beam_archive::LARGE_MEDIA_THRESHOLD { + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.display().to_string()); + self.large_media_prompt = Some(name); + } + } + + /// Render the one-time "pack vs reference large media" prompt, if pending. + /// The choice is persisted to config as the future default. + fn render_large_media_prompt(&mut self, ctx: &egui::Context) { + use lightningbeam_core::file_io::LargeMediaMode; + let Some(name) = self.large_media_prompt.clone() else { + return; + }; + let threshold_gb = lightningbeam_core::beam_archive::LARGE_MEDIA_THRESHOLD as f64 + / (1024.0 * 1024.0 * 1024.0); + let mut choice: Option = None; + egui::Window::new("Large media file") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .show(ctx, |ui| { + ui.label(format!("\"{}\" is larger than {:.0} GB.", name, threshold_gb)); + ui.add_space(6.0); + ui.label("How should large media be stored in projects from now on?"); + ui.add_space(6.0); + ui.label("• Pack — copy the bytes into the .beam file (self-contained, larger project)."); + ui.label("• Reference — keep the file on disk and store only its path (smaller project; the file must stay put)."); + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button("Pack into project").clicked() { + choice = Some(LargeMediaMode::Pack); + } + if ui.button("Reference external file").clicked() { + choice = Some(LargeMediaMode::Reference); + } + }); + ui.add_space(4.0); + ui.label( + egui::RichText::new("You can change this later in Preferences.") + .weak() + .small(), + ); + }); + if let Some(mode) = choice { + self.config.large_media_default = mode; + self.config.save(); + self.large_media_prompt = None; + } + } + /// Import an image file as an ImageAsset fn import_image(&mut self, path: &std::path::Path) -> Option { use lightningbeam_core::clip::ImageAsset; + self.note_possible_large_media(path); // Get filename for asset name let name = path.file_stem() @@ -3963,6 +4526,7 @@ impl EditorApp { /// GPU waveform cache. fn import_audio(&mut self, path: &std::path::Path) -> Option { use lightningbeam_core::clip::AudioClip; + self.note_possible_large_media(path); let name = path.file_stem() .and_then(|s| s.to_str()) @@ -4088,6 +4652,7 @@ impl EditorApp { fn import_video(&mut self, path: &std::path::Path) -> Option { use lightningbeam_core::clip::VideoClip; use lightningbeam_core::video::probe_video; + self.note_possible_large_media(path); let name = path.file_stem() .and_then(|s| s.to_str()) @@ -4129,82 +4694,95 @@ impl EditorApp { drop(video_mgr); // Spawn background thread to build keyframe index asynchronously - let video_manager_clone = Arc::clone(&self.video_manager); - let keyframe_clip_id = clip_id; - std::thread::spawn(move || { - let video_mgr = video_manager_clone.lock().unwrap(); - if let Err(e) = video_mgr.build_keyframe_index(&keyframe_clip_id) { - eprintln!("Failed to build keyframe index: {}", e); - } else { - println!(" Built keyframe index for video clip {}", keyframe_clip_id); - } - }); + self.spawn_keyframe_index(clip_id); - // Spawn background thread for audio extraction if video has audio + // Register the video's audio track as a streaming pool entry if present. if metadata.has_audio { if let Some(ref audio_controller) = self.audio_controller { let path_clone = path_str.clone(); let video_clip_id = clip_id; let video_name = name.clone(); + let video_duration = metadata.duration; let audio_controller_clone = Arc::clone(audio_controller); let tx = self.audio_extraction_tx.clone(); + let wf_tx = self.waveform_result_tx.clone(); + let floor_b = self.config.waveform_floor_samples_per_texel.max(1); std::thread::spawn(move || { - use lightningbeam_core::video::extract_audio_from_video; use lightningbeam_core::clip::AudioClip; - // Extract audio from video (slow FFmpeg operation) - match extract_audio_from_video(&path_clone) { - Ok(Some(extracted)) => { - // Add audio to daw-backend pool synchronously to get pool index - let pool_index = { - let mut controller = audio_controller_clone.lock().unwrap(); - match controller.add_audio_file_sync( - path_clone.clone(), - extracted.samples, - extracted.channels, - extracted.sample_rate, - ) { - Ok(index) => index, - Err(e) => { - eprintln!("Failed to add audio file to backend: {}", e); - let _ = tx.send(AudioExtractionResult::Error { - video_clip_id, - error: format!("Failed to add audio to backend: {}", e), - }); - return; - } - } - }; + // Add the video's audio track as a streaming pool entry — it is + // decoded on demand from the video file by the disk reader's + // FFmpeg backend. No extraction to disk or RAM. + let mut controller = audio_controller_clone.lock().unwrap(); + let pool_index = + match controller.add_video_audio_sync(std::path::PathBuf::from(&path_clone)) { + Ok(index) => index, + Err(e) => { + drop(controller); + eprintln!("Failed to add video audio stream: {}", e); + let _ = tx.send(AudioExtractionResult::Error { + video_clip_id, + error: format!("Failed to add video audio: {}", e), + }); + return; + } + }; + // Pull the audio track's channels/sample_rate for reporting + // (duration comes from the video itself, so the clip length + // matches the video clip exactly). + let (_dur, sample_rate, channels) = controller + .get_pool_file_info(pool_index) + .unwrap_or((video_duration, 0, 0)); + drop(controller); - // Create AudioClip - let audio_clip_name = format!("{} (Audio)", video_name); - let audio_clip = AudioClip::new_sampled( - &audio_clip_name, - pool_index, - extracted.duration, - ); + let audio_clip_name = format!("{} (Audio)", video_name); + let audio_clip = + AudioClip::new_sampled(&audio_clip_name, pool_index, video_duration); - // Send success result - let _ = tx.send(AudioExtractionResult::Success { - video_clip_id, - audio_clip, + let _ = tx.send(AudioExtractionResult::Success { + video_clip_id, + audio_clip, + pool_index, + video_name, + channels, + sample_rate, + }); + + // Build the min/max waveform overview pyramid by streaming the + // video's audio through FFmpeg once. Video-audio has no in-RAM + // samples, so without this the clip would draw no waveform. + use daw_backend::audio::disk_reader::{build_waveform_pyramid, SourceKind}; + match build_waveform_pyramid( + std::path::Path::new(&path_clone), + SourceKind::VideoAudio, + floor_b, + ) { + Ok(pyramid) => { + // Pack the floor level interleaved as (Lmin,Lmax,Rmin,Rmax) + // f32 per texel — the layout the GPU min/max upload expects. + let floor = pyramid.floor(); + let mut packed = Vec::with_capacity(floor.len() * 4); + for t in floor { + packed.push(t.l_min); + packed.push(t.l_max); + packed.push(t.r_min); + packed.push(t.r_max); + } + // Serialize the whole pyramid for persistence in the .beam + // container (so a later load is a disk read, not a re-decode). + let blob = pyramid.to_bytes(); + let _ = wf_tx.send(( pool_index, - video_name, - channels: extracted.channels, - sample_rate: extracted.sample_rate, - }); - } - Ok(None) => { - // Video has no audio stream - let _ = tx.send(AudioExtractionResult::NoAudio { video_clip_id }); + packed, + sample_rate, + channels, + pyramid.floor_samples_per_texel, + blob, + )); } Err(e) => { - // Audio extraction failed - let _ = tx.send(AudioExtractionResult::Error { - video_clip_id, - error: e, - }); + eprintln!("Failed to build waveform pyramid for video audio: {}", e); } } }); @@ -4213,63 +4791,10 @@ impl EditorApp { } } - // Spawn background thread for thumbnail generation - // Get decoder once, then generate thumbnails without holding VideoManager lock - let video_manager_clone = Arc::clone(&self.video_manager); - let duration = metadata.duration; - let thumb_clip_id = clip_id; - std::thread::spawn(move || { - // Get decoder Arc with brief lock - let decoder_arc = { - let video_mgr = video_manager_clone.lock().unwrap(); - match video_mgr.get_decoder(&thumb_clip_id) { - Some(arc) => arc, - None => { - eprintln!("Failed to get decoder for thumbnail generation"); - return; - } - } - }; - // VideoManager lock released - video can now be displayed! - - let interval = 5.0; - let mut t = 0.0; - let mut thumbnail_count = 0; - - while t < duration { - // Decode frame WITHOUT holding VideoManager lock - let thumb_opt = { - let mut decoder = decoder_arc.lock().unwrap(); - match decoder.decode_frame(t) { - Ok(rgba_data) => { - let w = decoder.get_output_width(); - let h = decoder.get_output_height(); - Some((rgba_data, w, h)) - } - Err(_) => None, - } - }; - - // Downsample without any locks - if let Some((rgba_data, w, h)) = thumb_opt { - use lightningbeam_core::video::downsample_rgba_public; - let thumb_w = 128u32; - let thumb_h = (h as f32 / w as f32 * thumb_w as f32) as u32; - let thumb_data = downsample_rgba_public(&rgba_data, w, h, thumb_w, thumb_h); - - // Brief lock just to insert - { - let mut video_mgr = video_manager_clone.lock().unwrap(); - video_mgr.insert_thumbnail(&thumb_clip_id, t, Arc::new(thumb_data)); - } - thumbnail_count += 1; - } - - t += interval; - } - - println!(" Generated {} thumbnails for video clip {}", thumbnail_count, thumb_clip_id); - }); + // Spawn background thread for thumbnail generation. The video becomes + // displayable as soon as the decoder is registered (above); thumbnails + // fill in without holding the VideoManager lock during decode. + self.spawn_thumbnail_generation(clip_id); // Add clip to document let clip_id = self.action_executor.document_mut().add_video_clip(clip); @@ -4377,15 +4902,22 @@ impl EditorApp { // Add clip instance or shape to the target layer if let Some(layer_id) = target_layer_id { - // For images, create a shape with image fill instead of a clip instance + // For images, create an image-filled rectangle on the (vector) layer, + // centered on the canvas at native size. if asset_info.clip_type == panes::DragClipType::Image { - // Get image dimensions - let (width, height) = asset_info.dimensions.unwrap_or((100.0, 100.0)); - - // TODO: Image fills on DCEL faces are a separate feature. - // For now, just log a message. - let _ = (layer_id, width, height); - eprintln!("Image drop to canvas not yet supported with DCEL backend"); + let (img_w, img_h) = asset_info.dimensions.unwrap_or((100.0, 100.0)); + let (doc_w, doc_h) = { + let d = self.action_executor.document(); + (d.width as f64, d.height as f64) + }; + let x = (doc_w - img_w) / 2.0; + let y = (doc_h - img_h) / 2.0; + let action = lightningbeam_core::actions::AddShapeAction::image_rect( + layer_id, self.playback_time, x, y, img_w, img_h, asset_info.clip_id, + ); + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Failed to place image: {}", e); + } } else { // For clips, create a clip instance let mut clip_instance = ClipInstance::new(asset_info.clip_id) @@ -4665,9 +5197,6 @@ impl EditorApp { eprintln!("⚠️ Audio extracted but VideoClip {} not found (may have been deleted)", video_clip_id); } } - AudioExtractionResult::NoAudio { video_clip_id } => { - println!("ℹ️ Video {} has no audio stream", video_clip_id); - } AudioExtractionResult::Error { video_clip_id, error } => { eprintln!("❌ Failed to extract audio from video {}: {}", video_clip_id, error); } @@ -4686,6 +5215,112 @@ impl eframe::App for EditorApp { fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { let _frame_start = std::time::Instant::now(); + // === Raster fault-in (Phase 3 paging) === + // The canvas records raster keyframe ids whose `raw_pixels` weren't resident + // (it can't mutate the document while rendering). Drain that sink here, BEFORE + // any rendering, and fault the pixels in from the project container. A drain of + // an empty set is ~free, so this is cheap when everything is already resident. + { + // 1) Apply completed background page-ins. + let mut any_applied = false; + while let Ok((kf_id, result)) = self.raster_load_result_rx.try_recv() { + self.raster_loads_inflight.remove(&kf_id); + let doc = self.action_executor.document_mut(); + let kf = doc.all_layers_mut().into_iter().find_map(|l| match l { + lightningbeam_core::layer::AnyLayer::Raster(rl) => { + rl.keyframes.iter_mut().find(|kf| kf.id == kf_id) + } + _ => None, + }); + if let Some(kf) = kf { + // Don't clobber pixels edited while the load was in flight. + if kf.needs_fault_in && kf.raw_pixels.is_empty() { + if let Some(pixels) = result { + kf.raw_pixels = pixels; + kf.texture_dirty = true; + any_applied = true; + // Record fault-in recency: most-recently paged-in is + // moved to the back so it's evicted last. The shown + // frame is always the most recent, so it's protected. + self.raster_resident_lru.retain(|id| *id != kf_id); + self.raster_resident_lru.push_back(kf_id); + } + // Resolved (loaded, or genuinely absent/blank). + kf.needs_fault_in = false; + } + } + } + if any_applied { + ctx.request_repaint(); + } + + // 1b) Evict over-budget resident keyframes (fault-in-recency LRU). + // Drop the pixels of the oldest *clean* keyframes and re-arm their + // fault-in so they re-page on revisit. DIRTY keyframes are never + // evicted (their pixels aren't in the container yet — evicting would + // silently lose the unsaved edit), so we just unpin them from the LRU. + while self.raster_resident_lru.len() > Self::RASTER_RESIDENT_MAX { + let old = self.raster_resident_lru.pop_front().unwrap(); + let doc = self.action_executor.document_mut(); + let kf = doc.all_layers_mut().into_iter().find_map(|l| match l { + lightningbeam_core::layer::AnyLayer::Raster(rl) => { + rl.keyframes.iter_mut().find(|kf| kf.id == old) + } + _ => None, + }); + if let Some(kf) = kf { + if !kf.dirty && !kf.raw_pixels.is_empty() { + kf.raw_pixels = Vec::new(); + kf.needs_fault_in = true; + kf.texture_dirty = true; + } + } + } + + // 2) Dispatch background page-ins: reactive misses, plus (during playback) + // a prefetch of upcoming keyframes so their full pixels are resident + // before the playhead reaches them — otherwise every frame shows the + // low-res proxy and the proxy→full pops read as flicker. + let mut to_load: Vec = { + let mut s = self.raster_fault_requests.lock().unwrap(); + s.drain().collect() + }; + if self.is_playing && self.raster_store.has_path() { + const PREFETCH_AHEAD: usize = 4; + let t = self.playback_time; + let doc = self.action_executor.document(); + for layer in doc.all_layers() { + if let lightningbeam_core::layer::AnyLayer::Raster(rl) = layer { + for kf in rl.keyframes.iter().filter(|kf| kf.time >= t).take(PREFETCH_AHEAD) { + if kf.raw_pixels.is_empty() && kf.needs_fault_in { + to_load.push(kf.id); + } + } + } + } + } + if self.raster_store.has_path() { + for kf_id in to_load { + if self.raster_loads_inflight.contains(&kf_id) { + continue; + } + self.raster_loads_inflight.insert(kf_id); + let store = self.raster_store.clone(); + let tx = self.raster_load_result_tx.clone(); + std::thread::spawn(move || { + let pixels = store.load_pixels(kf_id); + let _ = tx.send((kf_id, pixels)); + }); + } + } + + // Keep ticking while page-ins are running so their results get applied + // promptly (a background thread can't wake egui on its own). + if !self.raster_loads_inflight.is_empty() { + ctx.request_repaint(); + } + } + // Consume any pending tool switch from the tablet (eraser in/out). if let Some(tool) = self.tablet.pending_tool_switch.take() { self.selected_tool = tool; @@ -4702,6 +5337,17 @@ impl eframe::App for EditorApp { self.handle_audio_extraction_result(result); } + // Poll completed waveform-overview pyramids (packed min/max floors). + // Stored in the same cache the GPU upload reads from, but flagged as + // min/max so the renderer uploads via the (Lmin,Lmax,Rmin,Rmax) path. + while let Ok((pool_index, packed, sample_rate, channels, b, blob)) = self.waveform_result_rx.try_recv() { + self.raw_audio_cache.insert(pool_index, (Arc::new(packed), sample_rate, channels)); + self.waveform_minmax_pools.insert(pool_index, b.max(1)); + self.waveform_pyramid_blobs.insert(pool_index, Arc::new(blob)); + self.waveform_gpu_dirty.insert(pool_index); + self.audio_pools_with_new_waveforms.insert(pool_index); + } + // Webcam management: open/close based on camera_enabled layers, poll frames { let any_camera_enabled = self.action_executor.document().all_layers().iter().any(|layer| { @@ -4816,6 +5462,36 @@ impl eframe::App for EditorApp { FileProgress::Done => { println!("✅ Save complete!"); self.current_file_path = Some(path.clone()); + // Container path may be new (Save As); update the + // raster paging store so future faults read the right file. + self.raster_store.set_path(self.current_file_path.clone()); + + // All raster keyframes are now persisted in the + // container, so none are dirty — clearing the flag + // lets eviction reclaim their pixels again. + { + use lightningbeam_core::layer::AnyLayer; + let mut resident_ids = Vec::new(); + let doc = self.action_executor.document_mut(); + for layer in doc.all_layers_mut() { + if let AnyLayer::Raster(rl) = layer { + for kf in rl.keyframes.iter_mut() { + kf.dirty = false; + if !kf.raw_pixels.is_empty() { + resident_ids.push(kf.id); + } + } + } + } + // Frames edited this session were unpinned from the + // eviction LRU while dirty; now that they're clean and + // persisted, re-track them so the resident bound applies + // (the next fault-in trims the oldest, not the shown one). + for id in resident_ids { + self.raster_resident_lru.retain(|x| *x != id); + self.raster_resident_lru.push_back(id); + } + } // Add to recent files self.config.add_recent_file(path.clone()); @@ -5509,18 +6185,21 @@ impl eframe::App for EditorApp { } } - // Render video frames incrementally (if video export in progress) - if let Some(orchestrator) = &mut self.export_orchestrator { - if orchestrator.is_exporting() { - // Get GPU resources from eframe's wgpu render state - if let Some(render_state) = frame.wgpu_render_state() { - let device = &render_state.device; - let queue = &render_state.queue; + // One-time prompt: how to store large media (pack vs reference). + self.render_large_media_prompt(ctx); - // Create temporary renderer and image cache for export - // Note: Creating a new renderer per frame is inefficient but simple - // TODO: Reuse renderer across frames by storing it in EditorApp - let mut temp_renderer = vello::Renderer::new( + // Render video frames incrementally (if video export in progress) + let exporting = self.export_orchestrator.as_ref().map_or(false, |o| o.is_exporting()); + if exporting { + if let Some(render_state) = frame.wgpu_render_state() { + let device = &render_state.device; + let queue = &render_state.queue; + + // Build the renderer + image cache ONCE per export (reused every frame). + // The image cache is given the container path so lazily-paged image assets + // (Phase 4 Tier 1) decode during export. + if self.export_renderer.is_none() { + self.export_renderer = vello::Renderer::new( device, vello::RendererOptions { use_cpu: false, @@ -5529,41 +6208,52 @@ impl eframe::App for EditorApp { pipeline_cache: None, }, ).ok(); + let mut ic = lightningbeam_core::renderer::ImageCache::new(); + ic.set_container_path(self.current_file_path.clone()); + self.export_image_cache = Some(ic); + } - let mut temp_image_cache = lightningbeam_core::renderer::ImageCache::new(); - - if let Some(renderer) = &mut temp_renderer { - // Drive incremental video export. - if let Ok(has_more) = orchestrator.render_next_video_frame( - self.action_executor.document_mut(), - device, - queue, - renderer, - &mut temp_image_cache, - &self.video_manager, - ) { - if has_more { - ctx.request_repaint(); - } + if let (Some(renderer), Some(image_cache), Some(orchestrator)) = ( + self.export_renderer.as_mut(), + self.export_image_cache.as_mut(), + self.export_orchestrator.as_mut(), + ) { + // Drive incremental video export. + if let Ok(has_more) = orchestrator.render_next_video_frame( + self.action_executor.document_mut(), + device, + queue, + renderer, + image_cache, + &self.video_manager, + Some(&self.raster_store), + ) { + if has_more { + ctx.request_repaint(); } + } - // Drive single-frame image export (two-frame async: render then readback). - match orchestrator.render_image_frame( - self.action_executor.document_mut(), - device, - queue, - renderer, - &mut temp_image_cache, - &self.video_manager, - self.selection.raster_floating.as_ref(), - ) { - Ok(false) => { ctx.request_repaint(); } // readback pending - Ok(true) => {} // done or cancelled - Err(e) => { eprintln!("Image export failed: {e}"); } - } + // Drive single-frame image export (two-frame async: render then readback). + match orchestrator.render_image_frame( + self.action_executor.document_mut(), + device, + queue, + renderer, + image_cache, + &self.video_manager, + self.selection.raster_floating.as_ref(), + Some(&self.raster_store), + ) { + Ok(false) => { ctx.request_repaint(); } // readback pending + Ok(true) => {} // done or cancelled + Err(e) => { eprintln!("Image export failed: {e}"); } } } } + } else if self.export_renderer.is_some() { + // Export finished — free the renderer + cache. + self.export_renderer = None; + self.export_image_cache = None; } // Poll export orchestrator for progress @@ -5616,11 +6306,8 @@ impl eframe::App for EditorApp { // Close the progress dialog after a brief delay self.export_progress_dialog.close(); - // Send desktop notification - if let Err(e) = notifications::notify_export_complete(output_path) { - // Log but don't fail - notifications are non-critical - eprintln!("⚠️ Could not send desktop notification: {}", e); - } + // Send desktop notification (fire-and-forget; never blocks the UI) + notifications::notify_export_complete(output_path); } lightningbeam_core::export::ExportProgress::Error { ref message } => { eprintln!("❌ Export error: {}", message); @@ -5630,11 +6317,8 @@ impl eframe::App for EditorApp { ); // Keep the dialog open to show the error - // Send desktop notification for error - if let Err(e) = notifications::notify_export_error(message) { - // Log but don't fail - notifications are non-critical - eprintln!("⚠️ Could not send desktop notification: {}", e); - } + // Send desktop notification for error (fire-and-forget) + notifications::notify_export_error(message); } } } @@ -5778,6 +6462,14 @@ impl eframe::App for EditorApp { // Create render context let mut ctx = RenderContext { shared: panes::SharedPaneState { + container_path: self.current_file_path.clone(), + onion: { + // Onion skinning is disabled during playback. + let mut o = self.onion_skin; + o.enabled = o.enabled && !self.is_playing; + o + }, + onion_skin: &mut self.onion_skin, tool_icon_cache: &mut self.tool_icon_cache, icon_cache: &mut self.icon_cache, selected_tool: &mut self.selected_tool, @@ -5831,6 +6523,8 @@ impl eframe::App for EditorApp { audio_pools_with_new_waveforms: &self.audio_pools_with_new_waveforms, raw_audio_cache: &self.raw_audio_cache, waveform_gpu_dirty: &mut self.waveform_gpu_dirty, + waveform_minmax_pools: &self.waveform_minmax_pools, + raster_fault_requests: &self.raster_fault_requests, effect_to_load: &mut self.effect_to_load, effect_thumbnail_requests: &mut effect_thumbnail_requests, effect_thumbnail_cache: self.effect_thumbnail_generator.as_ref() @@ -5851,7 +6545,7 @@ impl eframe::App for EditorApp { graph_topology_generation: &mut self.graph_topology_generation, script_to_edit: &mut self.script_to_edit, script_saved: &mut self.script_saved, - region_selection: &mut self.region_selection, + pending_region_cut_base: &mut self.pending_region_cut_base, region_select_mode: &mut self.region_select_mode, lasso_mode: &mut self.lasso_mode, pending_graph_loads: &self.pending_graph_loads, @@ -6072,24 +6766,9 @@ impl eframe::App for EditorApp { } } - // Generate thumbnails in background - let vm_clone = Arc::clone(&self.video_manager); - std::thread::spawn(move || { - // Build keyframe index first - { - let vm = vm_clone.lock().unwrap(); - if let Err(e) = vm.build_keyframe_index(&clip_id) { - eprintln!("[WEBCAM] Failed to build keyframe index: {e}"); - } - } - // Generate thumbnails - { - let mut vm = vm_clone.lock().unwrap(); - if let Err(e) = vm.generate_thumbnails(&clip_id, duration) { - eprintln!("[WEBCAM] Failed to generate thumbnails: {e}"); - } - } - }); + // Build keyframe index + thumbnails in background. + self.spawn_keyframe_index(clip_id); + self.spawn_thumbnail_generation(clip_id); eprintln!( "[WEBCAM] probe_video: duration={:.4}s, fps={:.1}, {}x{}. Using probe duration for clip.", @@ -6305,21 +6984,21 @@ impl eframe::App for EditorApp { } } - // Escape key: cancel floating raster selection or revert uncommitted region selection + // Escape key: cancel floating raster selection, or clear the geometry selection if !wants_keyboard && ctx.input(|i| self.keymap.action_pressed(keymap::AppAction::CancelAction, i)) { if self.selection.raster_floating.is_some() { self.cancel_raster_floating(); } else if self.selection.raster_selection.is_some() { self.selection.raster_selection = None; - } else if self.region_selection.is_some() { - Self::revert_region_selection( - &mut self.region_selection, - &mut self.action_executor, - &mut self.selection, - ); + } else if self.selection.has_geometry_selection() { + self.selection.clear_geometry_selection(); } } + // After all input is processed, if a region selection was deselected without any + // intervening edit, heal (undo) its cut so the lasso is non-destructive. + self.resolve_pending_region_cut(); + // F3 debug overlay toggle (works even when text input is active) if ctx.input(|i| self.keymap.action_pressed(keymap::AppAction::ToggleDebugOverlay, i)) { self.debug_overlay_visible = !self.debug_overlay_visible; diff --git a/lightningbeam-ui/lightningbeam-editor/src/menu.rs b/lightningbeam-ui/lightningbeam-editor/src/menu.rs index 2d55671..ddf0daf 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/menu.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/menu.rs @@ -332,6 +332,7 @@ pub enum MenuAction { ZoomOut, ActualSize, RecenterView, + ToggleOnionSkin, NextLayout, PreviousLayout, #[allow(dead_code)] // Handler exists in main.rs, menu item not yet wired @@ -432,6 +433,7 @@ impl MenuItemDef { const ZOOM_OUT: Self = Self { label: "Zoom Out", action: MenuAction::ZoomOut, shortcut: Some(Shortcut::new(ShortcutKey::Minus, CTRL, NO_SHIFT, NO_ALT)) }; const ACTUAL_SIZE: Self = Self { label: "Actual Size", action: MenuAction::ActualSize, shortcut: Some(Shortcut::new(ShortcutKey::Num0, CTRL, NO_SHIFT, NO_ALT)) }; const RECENTER_VIEW: Self = Self { label: "Recenter View", action: MenuAction::RecenterView, shortcut: None }; + const TOGGLE_ONION_SKIN: Self = Self { label: "Onion Skinning", action: MenuAction::ToggleOnionSkin, shortcut: Some(Shortcut::new(ShortcutKey::O, NO_CTRL, NO_SHIFT, NO_ALT)) }; const NEXT_LAYOUT: Self = Self { label: "Next Layout", action: MenuAction::NextLayout, shortcut: Some(Shortcut::new(ShortcutKey::BracketRight, CTRL, NO_SHIFT, NO_ALT)) }; const PREVIOUS_LAYOUT: Self = Self { label: "Previous Layout", action: MenuAction::PreviousLayout, shortcut: Some(Shortcut::new(ShortcutKey::BracketLeft, CTRL, NO_SHIFT, NO_ALT)) }; @@ -455,6 +457,7 @@ impl MenuItemDef { &Self::DELETE, &Self::SELECT_ALL, &Self::SELECT_NONE, &Self::GROUP, &Self::ADD_LAYER, &Self::NEW_KEYFRAME, &Self::ZOOM_IN, &Self::ZOOM_OUT, &Self::ACTUAL_SIZE, + &Self::TOGGLE_ONION_SKIN, &Self::NEXT_LAYOUT, &Self::PREVIOUS_LAYOUT, &Self::SETTINGS, &Self::CLOSE_WINDOW, ] @@ -566,6 +569,7 @@ impl MenuItemDef { MenuDef::Item(&Self::ZOOM_OUT), MenuDef::Item(&Self::ACTUAL_SIZE), MenuDef::Item(&Self::RECENTER_VIEW), + MenuDef::Item(&Self::TOGGLE_ONION_SKIN), MenuDef::Separator, MenuDef::Submenu { label: "Layout", diff --git a/lightningbeam-ui/lightningbeam-editor/src/notifications.rs b/lightningbeam-ui/lightningbeam-editor/src/notifications.rs index 573626e..2222d58 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/notifications.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/notifications.rs @@ -3,52 +3,52 @@ use notify_rust::Notification; use std::path::Path; -/// Send a desktop notification for successful export -/// -/// # Arguments -/// * `output_path` - Path to the exported file -/// -/// # Returns -/// Ok(()) if notification sent successfully, Err with log message if failed -pub fn notify_export_complete(output_path: &Path) -> Result<(), String> { +// `notify_rust`'s `Notification::show()` is a SYNCHRONOUS D-Bus call. When no +// notification daemon is running it can block for the service-activation timeout +// (~25s) before failing, so these are fire-and-forget on a detached thread — the +// UI must never wait on them (e.g. they're triggered from the export-complete +// handler on the UI thread). + +/// Show a desktop notification for a successful export (fire-and-forget). +pub fn notify_export_complete(output_path: &Path) { let filename = output_path .file_name() .and_then(|n| n.to_str()) - .unwrap_or("unknown"); + .unwrap_or("unknown") + .to_string(); - Notification::new() - .summary("Export Complete") - .body(&format!("Successfully exported: {}", filename)) - .icon("dialog-information") // Standard icon name (freedesktop.org) - .timeout(5000) // 5 seconds - .show() - .map_err(|e| format!("Failed to show notification: {}", e))?; - - Ok(()) + std::thread::spawn(move || { + if let Err(e) = Notification::new() + .summary("Export Complete") + .body(&format!("Successfully exported: {}", filename)) + .icon("dialog-information") // Standard icon name (freedesktop.org) + .timeout(5000) // 5 seconds + .show() + { + eprintln!("⚠️ Could not send desktop notification: {}", e); + } + }); } -/// Send a desktop notification for export error -/// -/// # Arguments -/// * `error_message` - The error message to display -/// -/// # Returns -/// Ok(()) if notification sent successfully, Err with log message if failed -pub fn notify_export_error(error_message: &str) -> Result<(), String> { - // Truncate very long error messages - let truncated = if error_message.len() > 100 { - format!("{}...", &error_message[..97]) +/// Show a desktop notification for an export error (fire-and-forget). +pub fn notify_export_error(error_message: &str) { + // Truncate very long error messages (on a char boundary). + let truncated = if error_message.chars().count() > 100 { + let prefix: String = error_message.chars().take(97).collect(); + format!("{}...", prefix) } else { error_message.to_string() }; - Notification::new() - .summary("Export Failed") - .body(&truncated) - .icon("dialog-error") // Standard error icon - .timeout(10000) // 10 seconds for errors (longer to read) - .show() - .map_err(|e| format!("Failed to show notification: {}", e))?; - - Ok(()) + std::thread::spawn(move || { + if let Err(e) = Notification::new() + .summary("Export Failed") + .body(&truncated) + .icon("dialog-error") // Standard error icon + .timeout(10000) // 10 seconds for errors (longer to read) + .show() + { + eprintln!("⚠️ Could not send desktop notification: {}", e); + } + }); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 3b16748..ba4b56a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -12,7 +12,7 @@ use eframe::egui::{self, DragValue, Ui}; use lightningbeam_core::brush_settings::{bundled_brushes, BrushSettings}; -use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction, SetFillPaintAction}; +use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction, SetFillPaintAction, SetImageFillAction}; use lightningbeam_core::gradient::ShapeGradient; use lightningbeam_core::layer::{AnyLayer, LayerTrait}; use lightningbeam_core::selection::FocusSelection; @@ -785,6 +785,26 @@ impl InfopanelPane { let edge_ids: Vec = shared.selection.selected_edges() .iter().map(|eid| lightningbeam_core::vector_graph::EdgeId(eid.0)).collect(); + // Image-fill state for the selected fills + the document's image assets, for + // the picker below. Gathered now (immutable read) before `shared` is borrowed mut. + let image_assets: Vec<(Uuid, String)> = { + let doc = shared.action_executor.document(); + let mut v: Vec<(Uuid, String)> = doc.image_assets.iter() + .map(|(id, a)| (*id, a.name.clone())).collect(); + v.sort_by(|a, b| a.1.cmp(&b.1)); + v + }; + let current_image_fill: Option = { + let doc = shared.action_executor.document(); + match doc.get_layer(&layer_id) { + Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => vl + .graph_at_time(time) + .and_then(|g| face_ids.first().map(|&fid| g.fill(fid).image_fill)) + .flatten(), + _ => None, + } + }; + egui::CollapsingHeader::new("Shape") .id_salt(("shape", path)) .default_open(self.shape_section_open) @@ -792,47 +812,67 @@ impl InfopanelPane { self.shape_section_open = true; ui.add_space(4.0); - // Fill — determine current fill type + // Fill — determine current fill type. `image_fill` takes render priority, + // so when set it's the active type (overriding colour/gradient underneath); + // switching to None/Solid/Gradient clears it. + let has_image = current_image_fill.is_some(); let has_gradient = matches!(&info.fill_gradient, Some(Some(_))); let has_solid = matches!(&info.fill_color, Some(Some(_))); let fill_is_none = matches!(&info.fill_color, Some(None)) && matches!(&info.fill_gradient, Some(None)); let fill_mixed = info.fill_color.is_none() && info.fill_gradient.is_none(); - // Fill type toggle row + // Fill type toggle row: None | Solid | Gradient | Image ui.horizontal(|ui| { ui.label("Fill:"); if fill_mixed { ui.label("--"); } else { - if ui.selectable_label(fill_is_none, "None").clicked() && !fill_is_none { - let action = SetFillPaintAction::solid( - layer_id, time, face_ids.clone(), None, - ); - shared.pending_actions.push(Box::new(action)); + if ui.selectable_label(fill_is_none && !has_image, "None").clicked() { + if has_image { + shared.pending_actions.push(Box::new( + SetImageFillAction::new(layer_id, time, face_ids.clone(), None))); + } + shared.pending_actions.push(Box::new( + SetFillPaintAction::solid(layer_id, time, face_ids.clone(), None))); } - if ui.selectable_label(has_solid || (!has_gradient && !fill_is_none), "Solid").clicked() && !has_solid { - // Switch to solid: use existing color or default to black - let color = info.fill_color.flatten() - .unwrap_or(ShapeColor::rgba(0, 0, 0, 255)); - let action = SetFillPaintAction::solid( - layer_id, time, face_ids.clone(), Some(color), - ); - shared.pending_actions.push(Box::new(action)); + if ui.selectable_label(has_solid && !has_image, "Solid").clicked() { + if has_image { + shared.pending_actions.push(Box::new( + SetImageFillAction::new(layer_id, time, face_ids.clone(), None))); + } + if !has_solid { + let color = info.fill_color.flatten() + .unwrap_or(ShapeColor::rgba(0, 0, 0, 255)); + shared.pending_actions.push(Box::new( + SetFillPaintAction::solid(layer_id, time, face_ids.clone(), Some(color)))); + } } - if ui.selectable_label(has_gradient, "Gradient").clicked() && !has_gradient { - let grad = info.fill_gradient.clone().flatten() - .unwrap_or_default(); - let action = SetFillPaintAction::gradient( - layer_id, time, face_ids.clone(), Some(grad), - ); - shared.pending_actions.push(Box::new(action)); + if ui.selectable_label(has_gradient && !has_image, "Gradient").clicked() { + if has_image { + shared.pending_actions.push(Box::new( + SetImageFillAction::new(layer_id, time, face_ids.clone(), None))); + } + if !has_gradient { + let grad = info.fill_gradient.clone().flatten().unwrap_or_default(); + shared.pending_actions.push(Box::new( + SetFillPaintAction::gradient(layer_id, time, face_ids.clone(), Some(grad)))); + } + } + // Image tab — only offered if there are imported image assets. + if !image_assets.is_empty() || has_image { + if ui.selectable_label(has_image, "Image").clicked() && !has_image { + if let Some((aid, _)) = image_assets.first() { + shared.pending_actions.push(Box::new( + SetImageFillAction::new(layer_id, time, face_ids.clone(), Some(*aid)))); + } + } } } }); // Solid fill color editor - if !fill_mixed && has_solid { + if !fill_mixed && has_solid && !has_image { if let Some(Some(color)) = info.fill_color { ui.horizontal(|ui| { let mut rgba = [color.r, color.g, color.b, color.a]; @@ -848,7 +888,7 @@ impl InfopanelPane { } // Gradient fill editor - if !fill_mixed && has_gradient { + if !fill_mixed && has_gradient && !has_image { if let Some(Some(mut grad)) = info.fill_gradient.clone() { if gradient_stop_editor(ui, &mut grad, &mut self.selected_shape_gradient_stop) { let action = SetFillPaintAction::gradient( @@ -859,6 +899,28 @@ impl InfopanelPane { } } + // Image fill editor — pick which asset (active Image type). + if has_image { + ui.horizontal(|ui| { + ui.label("Image:"); + let selected_text = current_image_fill + .and_then(|id| image_assets.iter().find(|(aid, _)| *aid == id)) + .map(|(_, n)| n.clone()) + .unwrap_or_else(|| "(missing)".to_string()); + egui::ComboBox::from_id_salt(("image_fill", path)) + .selected_text(selected_text) + .show_ui(ui, |ui| { + for (aid, name) in &image_assets { + if ui.selectable_label(current_image_fill == Some(*aid), name).clicked() { + shared.pending_actions.push(Box::new( + SetImageFillAction::new(layer_id, time, face_ids.clone(), Some(*aid)), + )); + } + } + }); + }); + } + // Stroke color ui.horizontal(|ui| { ui.label("Stroke:"); @@ -1035,6 +1097,29 @@ impl InfopanelPane { }); } + /// Render the onion-skinning view settings (global; not tied to selection). + fn render_onion_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState) { + egui::CollapsingHeader::new("Onion Skin") + .id_salt(("onion", path)) + .default_open(shared.onion_skin.enabled) + .show(ui, |ui| { + ui.add_space(4.0); + ui.checkbox(&mut shared.onion_skin.enabled, "Enabled"); + ui.add_enabled_ui(shared.onion_skin.enabled, |ui| { + ui.horizontal(|ui| { + ui.label("Frames before:"); + ui.add(DragValue::new(&mut shared.onion_skin.frames_before).range(0..=5)); + }); + ui.horizontal(|ui| { + ui.label("Frames after:"); + ui.add(DragValue::new(&mut shared.onion_skin.frames_after).range(0..=5)); + }); + ui.add(egui::Slider::new(&mut shared.onion_skin.opacity, 0.0..=1.0).text("Opacity")); + }); + ui.add_space(4.0); + }); + } + /// Render layer info section fn render_layer_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, layer_ids: &[Uuid]) { let document = shared.action_executor.document(); @@ -1478,6 +1563,12 @@ impl PaneRenderer for InfopanelPane { } } } + + // Onion-skinning view settings — always available, regardless of selection. + ui.add_space(8.0); + ui.separator(); + ui.add_space(4.0); + self.render_onion_section(ui, path, shared); }); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 027a839..ae78e67 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -143,7 +143,45 @@ pub fn find_sampled_audio_track(document: &lightningbeam_core::document::Documen } /// Shared state that all panes can access +/// Onion-skinning view settings (editor-only; not saved with the document). Ghosts the +/// active layer's neighbouring keyframes, warm-tinted for past and cool for future. +#[derive(Clone, Copy, Debug)] +pub struct OnionSkinSettings { + pub enabled: bool, + pub frames_before: usize, + pub frames_after: usize, + /// Opacity of the nearest ghost; further ghosts fall off linearly. + pub opacity: f32, +} + +impl Default for OnionSkinSettings { + fn default() -> Self { + Self { enabled: false, frames_before: 2, frames_after: 2, opacity: 0.35 } + } +} + +impl OnionSkinSettings { + /// RGB tint multipliers for past (warm) and future (cool) ghosts. + pub const PAST_TINT: [f32; 3] = [1.0, 0.45, 0.45]; + pub const FUTURE_TINT: [f32; 3] = [0.45, 0.6, 1.0]; + + /// Opacity for the `n`-th ghost away from the current frame (n = 1 is nearest), + /// linearly falling off so the furthest ghost is faintest. + pub fn ghost_opacity(&self, n: usize, total: usize) -> f32 { + if total == 0 { return self.opacity; } + let falloff = 1.0 - (n.saturating_sub(1) as f32) / (total as f32); + self.opacity * falloff.clamp(0.15, 1.0) + } +} + pub struct SharedPaneState<'a> { + /// Current `.beam` container path (for lazily paging image-asset bytes in the + /// renderer's ImageCache). `None` before the project is first saved/loaded. + pub container_path: Option, + /// Effective onion-skin settings (already gated to off during playback by main.rs). + pub onion: OnionSkinSettings, + /// The raw onion-skin settings, mutable — edited by the Info Panel's controls. + pub onion_skin: &'a mut OnionSkinSettings, pub tool_icon_cache: &'a mut crate::ToolIconCache, #[allow(dead_code)] // Used by pane chrome rendering in main.rs pub icon_cache: &'a mut crate::IconCache, @@ -242,6 +280,16 @@ pub struct SharedPaneState<'a> { pub raw_audio_cache: &'a std::collections::HashMap>, u32, u32)>, /// Pool indices needing GPU waveform texture upload pub waveform_gpu_dirty: &'a mut std::collections::HashSet, + /// Pools whose `raw_audio_cache` entry is a packed min/max floor rather than + /// raw samples (pool_index -> `B`, floor frames-per-texel). Drives the GPU + /// min/max upload path and the floor's effective rate `sr/B` in the renderer. + pub waveform_minmax_pools: &'a std::collections::HashMap, + /// Miss-sink for on-demand raster keyframe pixel faulting (Phase 3 paging). + /// The canvas inserts the id of any raster keyframe it wants to upload whose + /// `raw_pixels` aren't resident; the App drains this at the top of the next + /// `update()` and faults the pixels in from the project container. + pub raster_fault_requests: + &'a std::sync::Arc>>, /// Effect ID to load into shader editor (set by asset library, consumed by shader editor) pub effect_to_load: &'a mut Option, /// Queue for effect thumbnail requests (effect IDs to generate thumbnails for) @@ -277,8 +325,10 @@ pub struct SharedPaneState<'a> { pub script_to_edit: &'a mut Option, /// Script ID that was just saved (triggers auto-recompile of nodes using it) pub script_saved: &'a mut Option, - /// Active region selection (temporary split state) - pub region_selection: &'a mut Option, + /// Undo-stack depth recorded before the first region-select cut of the current + /// selection session. `Some` while a region selection is live and unchanged; lets a + /// later deselect heal (undo) the cut if nothing was edited. See `resolve_pending_region_cut`. + pub pending_region_cut_base: &'a mut Option, /// Region select mode (Rectangle or Lasso) pub region_select_mode: &'a mut lightningbeam_core::tool::RegionSelectMode, /// Lasso select sub-mode (Freehand / Polygonal / Magnetic) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/alpha_composite.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/alpha_composite.wgsl index 5012b86..8d02ada 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/alpha_composite.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/alpha_composite.wgsl @@ -5,12 +5,12 @@ // // B[px] = C[px] + A[px] * (1 − C[px].a) (Porter-Duff src-over, C over A) // -// All textures are Rgba8Unorm, linear premultiplied RGBA. +// All textures are Rgba16Float, linear premultiplied RGBA. // Dispatch: ceil(w/8) × ceil(h/8) × 1. @group(0) @binding(0) var tex_a: texture_2d; // source (A) @group(0) @binding(1) var tex_c: texture_2d; // accumulated dabs (C) -@group(0) @binding(2) var tex_b: texture_storage_2d; // output (B) +@group(0) @binding(2) var tex_b: texture_storage_2d; // output (B) @compute @workgroup_size(8, 8) fn main(@builtin(global_invocation_id) gid: vec3) { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl index 134b3dd..d2bac69 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/brush_dab.wgsl @@ -37,7 +37,7 @@ struct Params { @group(0) @binding(0) var dabs: array; @group(0) @binding(1) var params: Params; @group(0) @binding(2) var canvas_src: texture_2d; -@group(0) @binding(3) var canvas_dst: texture_storage_2d; +@group(0) @binding(3) var canvas_dst: texture_storage_2d; // --------------------------------------------------------------------------- // Manual bilinear sample from canvas_src at sub-pixel coordinates (px, py). diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_blit.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_blit.wgsl index e193544..e6e1db9 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_blit.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_blit.wgsl @@ -67,5 +67,11 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { let mask = textureSample(mask_tex, mask_sampler, canvas_uv).r; let masked_a = c.a * mask; let inv_a = select(0.0, 1.0 / c.a, c.a > 1e-6); - return vec4(c.r * inv_a, c.g * inv_a, c.b * inv_a, masked_a); + // RGB tint packed into the matrix uniform's unused .w slots ((0,0,0) = no tint). + // Screen-blended so it recolors blacks too (outlines → warm/cool ghosts) and + // leaves white alone: out = base + tint - base*tint. Used by onion-skin ghosts. + let tint = vec3(transform.col0.w, transform.col1.w, transform.col2.w); + let base = vec3(c.r * inv_a, c.g * inv_a, c.b * inv_a); + let tinted = base + tint - base * tint; + return vec4(tinted, masked_a); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_readback_srgb.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_readback_srgb.wgsl new file mode 100644 index 0000000..2bf3a46 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/canvas_readback_srgb.wgsl @@ -0,0 +1,27 @@ +// Canvas readback color conversion. +// +// Converts the Rgba16Float linear-PREMULTIPLIED canvas into an Rgba8Unorm +// sRGB-encoded premultiplied texture so the CPU readback is a pure row memcpy +// instead of a per-pixel sRGB decode. Matches the bytes the previous CPU decode +// produced: the sRGB OETF is applied per channel to the premultiplied RGB, and +// alpha (which is not gamma-encoded) is passed through. + +// linear_to_srgb_channel is provided by the prepended COLOR_WGSL prelude. +@group(0) @binding(0) var src: texture_2d; // linear premultiplied +@group(0) @binding(1) var dst: texture_storage_2d; // sRGB premultiplied + +@compute @workgroup_size(8, 8) +fn main(@builtin(global_invocation_id) gid: vec3) { + let dim = textureDimensions(src); + if gid.x >= dim.x || gid.y >= dim.y { + return; + } + let p = vec2(i32(gid.x), i32(gid.y)); + let c = textureLoad(src, p, 0); + let srgb = vec3( + linear_to_srgb_channel(c.r), + linear_to_srgb_channel(c.g), + linear_to_srgb_channel(c.b), + ); + textureStore(dst, p, vec4(srgb, c.a)); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/gradient_fill.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/gradient_fill.wgsl index 19e99db..286a16c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/gradient_fill.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/gradient_fill.wgsl @@ -2,8 +2,10 @@ // // Reads the anchor canvas (before_pixels), composites a gradient over it, and // writes the result to the display canvas. All color values in the canvas are -// linear premultiplied RGBA. The stop colors passed via `stops` are linear -// straight-alpha [0..1] (sRGB→linear conversion is done on the CPU). +// linear premultiplied RGBA. The stop colors passed via `stops` are sRGB +// straight-alpha [0..1]; the gradient is interpolated in sRGB (gamma) space to +// match the CPU raster and vector gradient paths, then the interpolated color is +// converted to linear before compositing. // // Dispatch: ceil(canvas_w / 8) × ceil(canvas_h / 8) × 1 @@ -25,7 +27,7 @@ struct Params { // 32 bytes per stop (8 × f32), matching `GpuGradientStop` on the Rust side. struct GradientStop { position: f32, - r: f32, // linear [0..1], straight-alpha + r: f32, // sRGB [0..1], straight-alpha g: f32, b: f32, a: f32, @@ -34,10 +36,11 @@ struct GradientStop { _pad2: f32, } +// srgb_to_linear_channel is provided by the prepended COLOR_WGSL prelude. @group(0) @binding(0) var params: Params; @group(0) @binding(1) var src: texture_2d; @group(0) @binding(2) var stops: array; -@group(0) @binding(3) var dst: texture_storage_2d; +@group(0) @binding(3) var dst: texture_storage_2d; fn apply_extend(t: f32) -> f32 { if params.extend_mode == 0u { @@ -122,7 +125,15 @@ fn main(@builtin(global_invocation_id) gid: vec3) { } let t = apply_extend(t_raw); - let grad = eval_gradient(t); // straight-alpha linear RGBA + let grad = eval_gradient(t); // straight-alpha sRGB RGBA (interpolated in gamma space) + + // Convert the interpolated sRGB color to linear for compositing. Alpha is + // not gamma-encoded, so it passes through unchanged. + let grad_rgb_lin = vec3( + srgb_to_linear_channel(grad.r), + srgb_to_linear_channel(grad.g), + srgb_to_linear_channel(grad.b), + ); // Effective alpha: gradient alpha × tool opacity. let a = grad.a * params.opacity; @@ -131,7 +142,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) { // src_px.rgb is premultiplied (= straight_rgb * src_a). // Output is also premultiplied. let out_a = a + src_px.a * (1.0 - a); - let out_rgb = grad.rgb * a + src_px.rgb * (1.0 - a); + let out_rgb = grad_rgb_lin * a + src_px.rgb * (1.0 - a); textureStore(dst, vec2(i32(gid.x), i32(gid.y)), vec4(out_rgb, out_a)); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/linear_to_srgb.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/linear_to_srgb.wgsl index 491afde..8537377 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/linear_to_srgb.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/linear_to_srgb.wgsl @@ -30,27 +30,23 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { return out; } -// Linear to sRGB conversion for a single channel -// Formula: c <= 0.0031308 ? c*12.92 : 1.055*pow(c, 1/2.4) - 0.055 -fn linear_to_srgb_channel(c: f32) -> f32 { - let clamped = clamp(c, 0.0, 1.0); - return select( - 1.055 * pow(clamped, 1.0 / 2.4) - 0.055, - clamped * 12.92, - clamped <= 0.0031308 - ); -} +// linear_to_srgb_channel is provided by the prepended COLOR_WGSL prelude. @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { - // Sample linear HDR texture + // Sample linear HDR texture. The compositor accumulates PREMULTIPLIED + // linear color, so unpremultiply before applying the sRGB OETF: + // srgb(rgb*a) != srgb(rgb)*a, so encoding premultiplied color directly + // corrupts antialiased edges and transparent pixels. We output straight + // alpha to match the straight-alpha display blit and PNG export. let linear = textureSample(input_tex, input_sampler, in.uv); + let a = linear.a; + let straight = select(linear.rgb / a, vec3(0.0), a <= 0.0); - // Convert from linear to sRGB for display (alpha stays linear) return vec4( - linear_to_srgb_channel(linear.r), - linear_to_srgb_channel(linear.g), - linear_to_srgb_channel(linear.b), - linear.a + linear_to_srgb_channel(straight.r), + linear_to_srgb_channel(straight.g), + linear_to_srgb_channel(straight.b), + a ); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/raster_transform.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/raster_transform.wgsl index 154376e..621f3ba 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/raster_transform.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/raster_transform.wgsl @@ -26,7 +26,7 @@ struct Params { @group(0) @binding(0) var params: Params; @group(0) @binding(1) var src: texture_2d; -@group(0) @binding(2) var dst: texture_storage_2d; +@group(0) @binding(2) var dst: texture_storage_2d; // Manual bilinear sample with clamp-to-edge (textureSample forbidden in compute shaders). fn bilinear_sample(px: f32, py: f32) -> vec4 { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/warp_apply.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/warp_apply.wgsl index 1bd04f3..e1af702 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/warp_apply.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/warp_apply.wgsl @@ -26,7 +26,7 @@ struct Params { @group(0) @binding(0) var params: Params; @group(0) @binding(1) var src: texture_2d; @group(0) @binding(2) var disp: array; -@group(0) @binding(3) var dst: texture_storage_2d; +@group(0) @binding(3) var dst: texture_storage_2d; // Manual bilinear sample with clamp-to-edge (textureSample forbidden in compute shaders). fn bilinear_sample(px: f32, py: f32) -> vec4 { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/waveform.wgsl b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/waveform.wgsl index 9a857ba..abeadac 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/waveform.wgsl +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shaders/waveform.wgsl @@ -77,27 +77,29 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { let frames_per_pixel = params.sample_rate / params.pixels_per_second; // Each mip level reduces by 4x in sample count (2x in each texture dimension) let mip_f = max(0.0, log2(frames_per_pixel) / 2.0); - let max_mip = f32(textureNumLevels(peak_tex) - 1u); - let mip = min(mip_f, max_mip); - // Frame index at the chosen mip level - let mip_floor = u32(mip); - let reduction = pow(4.0, f32(mip_floor)); + // Pick the NEAREST INTEGER LOD and read its exact texel. Sampling at a + // fractional mip (trilinear) blends level N and N+1, but each level has its + // own 1D→2D row-major linearization (width halves per level), so the two + // levels disagree on which audio frame a given screen column maps to. The + // blend then reads horizontally-offset neighbours, and because a 2x zoom step + // shifts mip_f by exactly 0.5, alternate zoom levels land on a clean integer + // (correct) vs a 50/50 blend (offset) — the "every other zoom level" artifact. + // textureLoad at one integer level keeps the frame→texel mapping exact. + let max_mip = i32(textureNumLevels(peak_tex)) - 1; + let mip_i = clamp(i32(mip_f + 0.5), 0, max_mip); + let reduction = pow(4.0, f32(mip_i)); let mip_frame = frame_f / reduction; - // Convert 1D mip-space index to 2D UV coordinates - // Use actual texture dimensions (not computed from total_frames) because the - // texture may be pre-allocated larger for live recording. - let mip_dims = textureDimensions(peak_tex, mip_floor); + // Convert 1D mip-space index to 2D texel coords using this level's actual + // dimensions (texture may be pre-allocated larger, e.g. for live recording). + let mip_dims = textureDimensions(peak_tex, mip_i); let mip_tex_width = f32(mip_dims.x); - let mip_tex_height = f32(mip_dims.y); - let texel_x = mip_frame % mip_tex_width; - let texel_y = floor(mip_frame / mip_tex_width); - let uv = vec2((texel_x + 0.5) / mip_tex_width, (texel_y + 0.5) / mip_tex_height); + let texel_x = i32(mip_frame % mip_tex_width); + let texel_y = i32(floor(mip_frame / mip_tex_width)); - // Sample the peak texture at computed mip level // R = left_min, G = left_max, B = right_min, A = right_max - let peak = textureSampleLevel(peak_tex, peak_sampler, uv, mip); + let peak = textureLoad(peak_tex, vec2(texel_x, texel_y), mip_i); let clip_height = params.clip_rect.w - params.clip_rect.y; let clip_top = params.clip_rect.y; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index e1f11b9..bbf139b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -217,7 +217,9 @@ impl SharedVelloResources { // Uses linear_to_srgb.wgsl which reads from Rgba16Float HDR texture let hdr_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("hdr_blit_shader"), - source: wgpu::ShaderSource::Wgsl(include_str!("shaders/linear_to_srgb.wgsl").into()), + source: wgpu::ShaderSource::Wgsl( + format!("{}\n{}", lightningbeam_core::gpu::COLOR_WGSL, include_str!("shaders/linear_to_srgb.wgsl")).into(), + ), }); let hdr_blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { @@ -433,6 +435,12 @@ struct VelloRenderContext { document: std::sync::Arc, /// Current tool interaction state tool_state: lightningbeam_core::tool::ToolState, + /// Onion-skinning settings (already gated off during playback). + onion: crate::panes::OnionSkinSettings, + /// `.beam` container path for lazily paging image-asset bytes in the ImageCache. + container_path: Option, + /// Whether playback is active (gates image prefetch). + is_playing: bool, /// Active layer for tool operations active_layer_id: Option, /// Delta for drag preview (world space) @@ -464,8 +472,6 @@ struct VelloRenderContext { editing_instance_id: Option, /// The parent layer ID containing the clip instance being edited editing_parent_layer_id: Option, - /// Active region selection state (for rendering boundary overlay) - region_selection: Option, /// Mouse position in document-local (clip-local) world coordinates, for hover hit testing mouse_world_pos: Option, /// Latest webcam frame for live preview (if any camera is active) @@ -521,6 +527,12 @@ struct VelloRenderContext { /// When `Some`, readback this B-canvas into `RASTER_READBACK_RESULTS` after /// dispatching GPU tool work. Set on mouseup by the unified raster tool commit path. pending_tool_readback_b: Option, + /// Miss-sink for on-demand raster keyframe pixel faulting (Phase 3 paging). + /// When the compositor wants to upload an idle raster keyframe whose `raw_pixels` + /// aren't resident, it inserts the keyframe id here; the App drains this at the + /// top of the next `update()` and faults the pixels in from the project container. + raster_fault_requests: + std::sync::Arc>>, } /// Callback for Vello rendering within egui @@ -965,6 +977,8 @@ impl egui_wgpu::CallbackTrait for VelloCallback { let _t_after_gpu_dispatches = std::time::Instant::now(); let mut image_cache = shared.image_cache.lock().unwrap(); + // Let the cache page image bytes from the project container on a decode miss. + image_cache.set_container_path(self.ctx.container_path.clone()); let composite_result = if shared.is_cpu_renderer { lightningbeam_core::renderer::render_document_for_compositing_cpu( @@ -989,6 +1003,66 @@ impl egui_wgpu::CallbackTrait for VelloCallback { true, // Draw checkerboard for transparent backgrounds in the UI ) }; + + // Onion-skin ghost scenes for the active VECTOR layer (raster ghosts are + // handled in the render loop). Built at each neighbouring keyframe's time + // via the isolated-layer renderer; rendered + tinted before the active + // scene below. `ctx.onion.enabled` is already gated off during playback. + let mut onion_vector_ghosts: Vec<(vello::Scene, [f32; 3], f32)> = Vec::new(); + if self.ctx.onion.enabled && !shared.is_cpu_renderer { + if let Some(active_id) = self.ctx.active_layer_id { + if let Some(layer) = self.ctx.document.get_layer(&active_id) { + if let lightningbeam_core::layer::AnyLayer::Vector(vl) = layer { + let onion = self.ctx.onion; + let after = vl.keyframes.partition_point(|kf| kf.time <= self.ctx.playback_time); + if after > 0 { + let cur = after - 1; + let mut want: Vec<(usize, [f32; 3], f32)> = Vec::new(); + for d in 1..=onion.frames_before { + if cur >= d { + want.push((cur - d, crate::panes::OnionSkinSettings::PAST_TINT, + onion.ghost_opacity(d, onion.frames_before))); + } + } + for d in 1..=onion.frames_after { + if cur + d < vl.keyframes.len() { + want.push((cur + d, crate::panes::OnionSkinSettings::FUTURE_TINT, + onion.ghost_opacity(d, onion.frames_after))); + } + } + for (idx, tint, op) in want { + let kf_time = vl.keyframes[idx].time; + let rl = lightningbeam_core::renderer::render_layer_isolated( + &self.ctx.document, kf_time, layer, camera_transform, + &mut image_cache, &shared.video_manager, + self.ctx.webcam_frame.as_ref(), + ); + if rl.has_content { + onion_vector_ghosts.push((rl.scene, tint, op)); + } + } + } + } + } + } + } + + // Prefetch: during playback, decode images the upcoming frames will need + // (a short lookahead) into the bounded cache, so a keyframe that swaps image + // fills doesn't hitch when the playhead reaches it. + if self.ctx.is_playing { + const PREFETCH_LOOKAHEAD: f64 = 0.5; + let ahead = lightningbeam_core::renderer::assets_needed_at( + &self.ctx.document, + self.ctx.playback_time + PREFETCH_LOOKAHEAD, + ); + for id in ahead { + if let Some(asset) = self.ctx.document.get_image_asset(&id) { + let _ = image_cache.get_or_decode(asset); + } + } + } + drop(image_cache); let _t_after_scene_build = std::time::Instant::now(); @@ -1167,6 +1241,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // 4. Raster layer texture cache: for idle raster layers (no active tool canvas). // Upload raw_pixels to the cache if texture_dirty; then use the cache entry. + // If the full pixels aren't resident, fall back to the low-res proxy: + // (kf_id, logical_w, logical_h) so the blit upscales it to full size. + let mut raster_proxy_blit: Option<(uuid::Uuid, u32, u32)> = None; let raster_cache_kf: Option = if gpu_canvas_kf.is_none() { // Find the active keyframe for this raster layer. let doc = &self.ctx.document; @@ -1204,6 +1281,24 @@ impl egui_wgpu::CallbackTrait for VelloCallback { ); Some(kf_id) } else { + // Empty pixels: if the frame is paged out (lives in the + // container), record a fault-in request so the App pages it + // in at the top of the next frame. A new blank keyframe + // (needs_fault_in == false) has nothing to load — skip it. + if kf.needs_fault_in { + if let Ok(mut reqs) = self.ctx.raster_fault_requests.lock() { + reqs.insert(kf_id); + } + } + // Show the low-res proxy (if decoded) while the full + // pages in, so the cold scrub doesn't flash blank. + if let Some(proxy) = &kf.proxy { + gpu_brush.ensure_proxy_texture( + device, queue, kf_id, + &proxy.pixels, proxy.width, proxy.height, + ); + raster_proxy_blit = Some((kf_id, kf.width, kf.height)); + } None } } else { @@ -1219,12 +1314,42 @@ impl egui_wgpu::CallbackTrait for VelloCallback { None }; - if !rendered_layer.has_content && gpu_canvas_kf.is_none() && raster_cache_kf.is_none() { - continue; - } - match &rendered_layer.layer_type { RenderedLayerType::Vector => { + // Onion-skin ghosts for the active vector layer: each neighbour + // scene → sRGB → linear → composite with a screen tint + falloff, + // behind the current frame. + if Some(rendered_layer.layer_id) == self.ctx.active_layer_id { + for (ghost_scene, tint, opacity) in &onion_vector_ghosts { + let gsrgb = buffer_pool.acquire(device, layer_spec); + let ghdr = buffer_pool.acquire(device, hdr_spec); + if let (Some(gsrgb_view), Some(ghdr_view), Some(hdr_view)) = ( + buffer_pool.get_view(gsrgb), + buffer_pool.get_view(ghdr), + &instance_resources.hdr_texture_view, + ) { + if let Ok(mut renderer) = shared.renderer.lock() { + renderer.render_to_texture(device, queue, ghost_scene, gsrgb_view, &layer_render_params).ok(); + } + let mut conv = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("onion_vec_ghost_convert"), + }); + shared.srgb_to_linear.convert(device, &mut conv, gsrgb_view, ghdr_view); + queue.submit(Some(conv.finish())); + let cl = lightningbeam_core::gpu::CompositorLayer::new( + ghdr, rendered_layer.opacity * opacity, rendered_layer.blend_mode, + ).with_tint(tint[0], tint[1], tint[2]); + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("onion_vec_ghost_composite"), + }); + shared.compositor.composite(device, queue, &mut enc, &[cl], &buffer_pool, hdr_view, None); + queue.submit(Some(enc.finish())); + } + buffer_pool.release(gsrgb); + buffer_pool.release(ghdr); + } + } + // Vector/group layer — render Vello scene → sRGB → linear → composite. let srgb_handle = buffer_pool.acquire(device, layer_spec); let hdr_layer_handle = buffer_pool.acquire(device, hdr_spec); @@ -1265,26 +1390,160 @@ impl egui_wgpu::CallbackTrait for VelloCallback { buffer_pool.release(hdr_layer_handle); } RenderedLayerType::Raster { transform: layer_transform, .. } => { + // --- Onion-skin ghosts: the active layer's neighbouring keyframes, + // warm-tinted for past / cool for future, faint and behind the + // current frame. `ctx.onion.enabled` is already gated off during + // playback. Ghosts use the full texture if resident, else the proxy. + if self.ctx.onion.enabled + && Some(rendered_layer.layer_id) == self.ctx.active_layer_id + { + let onion = self.ctx.onion; + let ghosts: Vec<(uuid::Uuid, u32, u32, [f32; 3], f32)> = { + let doc = &self.ctx.document; + match doc.get_layer(&rendered_layer.layer_id) { + Some(lightningbeam_core::layer::AnyLayer::Raster(rl)) => { + let after = rl.keyframes.partition_point(|kf| kf.time <= self.ctx.playback_time); + let mut v = Vec::new(); + if after > 0 { + let cur = after - 1; + for d in 1..=onion.frames_before { + if cur >= d { + let kf = &rl.keyframes[cur - d]; + v.push((kf.id, kf.width, kf.height, + crate::panes::OnionSkinSettings::PAST_TINT, + onion.ghost_opacity(d, onion.frames_before))); + } + } + for d in 1..=onion.frames_after { + if cur + d < rl.keyframes.len() { + let kf = &rl.keyframes[cur + d]; + v.push((kf.id, kf.width, kf.height, + crate::panes::OnionSkinSettings::FUTURE_TINT, + onion.ghost_opacity(d, onion.frames_after))); + } + } + } + v + } + _ => Vec::new(), + } + }; + for (kf_id, lw, lh, tint, opacity) in ghosts { + let gh = buffer_pool.acquire(device, hdr_spec); + if let (Some(gv), Some(hdr_view)) = ( + buffer_pool.get_view(gh), + &instance_resources.hdr_texture_view, + ) { + let mut drew = false; + let mut need_fault = false; + if let Ok(mut gpu_brush) = shared.gpu_brush.lock() { + // Resolve a texture, in priority order: + // 1) cached full texture + // 2) resident raw_pixels → upload full (e.g. an + // unsaved neighbour whose GPU texture evicted) + // 3) in-memory proxy → upload proxy + // 4) none → request a fault-in (seek to a paged-out frame) + let resolved: Option<(u32, u32, bool)> = + if gpu_brush.raster_layer_cache.contains_key(&kf_id) { + gpu_brush.raster_layer_cache.get(&kf_id).map(|c| (c.width, c.height, false)) + } else { + let kf = match self.ctx.document.get_layer(&rendered_layer.layer_id) { + Some(lightningbeam_core::layer::AnyLayer::Raster(rl)) => + rl.keyframes.iter().find(|k| k.id == kf_id), + _ => None, + }; + match kf { + Some(kf) if kf.raw_pixels.len() == (kf.width * kf.height * 4) as usize => { + gpu_brush.ensure_layer_texture(device, queue, kf_id, &kf.raw_pixels, kf.width, kf.height, false); + Some((kf.width, kf.height, false)) + } + Some(kf) if kf.proxy.is_some() => { + let p = kf.proxy.as_ref().unwrap(); + gpu_brush.ensure_proxy_texture(device, queue, kf_id, &p.pixels, p.width, p.height); + Some((lw, lh, true)) + } + Some(kf) if kf.needs_fault_in => { need_fault = true; None } + _ => None, + } + }; + if let Some((sw, sh, is_proxy)) = resolved { + let tex = if is_proxy { + gpu_brush.get_proxy_texture(&kf_id) + } else { + gpu_brush.raster_layer_cache.get(&kf_id) + }; + if let Some(canvas) = tex { + let bt = crate::gpu_brush::BlitTransform::new(*layer_transform, sw, sh, width, height) + .with_tint(tint[0], tint[1], tint[2]); + if is_proxy { + shared.canvas_blit.blit_smooth(device, queue, canvas.src_view(), gv, &bt, None); + } else { + shared.canvas_blit.blit(device, queue, canvas.src_view(), gv, &bt, None); + } + drew = true; + } + } + } + if need_fault { + if let Ok(mut reqs) = self.ctx.raster_fault_requests.lock() { + reqs.insert(kf_id); + } + } + if drew { + let cl = lightningbeam_core::gpu::CompositorLayer::new( + gh, rendered_layer.opacity * opacity, rendered_layer.blend_mode, + ); + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("onion_ghost_encoder"), + }); + shared.compositor.composite(device, queue, &mut enc, &[cl], &buffer_pool, hdr_view, None); + queue.submit(Some(enc.finish())); + } + } + buffer_pool.release(gh); + } + } + // Raster layer — GPU canvas blit directly to HDR (bypasses Vello). - // Tool override canvas (gpu_canvas_kf) takes priority over cached texture. - if let Some(use_kf_id) = gpu_canvas_kf.or(raster_cache_kf) { + // Tool override canvas (gpu_canvas_kf) takes priority over cached + // texture; if neither full-res source is present, the low-res proxy. + let full_kf = gpu_canvas_kf.or(raster_cache_kf); + if full_kf.is_some() || raster_proxy_blit.is_some() { let hdr_layer_handle = buffer_pool.acquire(device, hdr_spec); if let (Some(hdr_layer_view), Some(hdr_view)) = ( buffer_pool.get_view(hdr_layer_handle), &instance_resources.hdr_texture_view, ) { if let Ok(gpu_brush) = shared.gpu_brush.lock() { - let canvas = gpu_brush.canvases.get(&use_kf_id) - .or_else(|| gpu_brush.raster_layer_cache.get(&use_kf_id)); - if let Some(canvas) = canvas { + // Pick the source texture and the LOGICAL dims the blit + // should map it to. A full texture uses its own dims; a + // proxy uses the keyframe's full dims so it upscales. + let blit = if let Some(use_kf_id) = full_kf { + gpu_brush.canvases.get(&use_kf_id) + .or_else(|| gpu_brush.raster_layer_cache.get(&use_kf_id)) + .map(|c| (c, c.width, c.height, false)) + } else if let Some((pkf, lw, lh)) = raster_proxy_blit { + gpu_brush.get_proxy_texture(&pkf).map(|c| (c, lw, lh, true)) + } else { + None + }; + if let Some((canvas, logical_w, logical_h, is_proxy)) = blit { let bt = crate::gpu_brush::BlitTransform::new( *layer_transform, - canvas.width, canvas.height, + logical_w, logical_h, width, height, ); - shared.canvas_blit.blit( - device, queue, canvas.src_view(), hdr_layer_view, &bt, None, - ); + // Proxies are upscaled, so sample them bilinearly; + // the real canvas stays nearest (crisp pixels). + if is_proxy { + shared.canvas_blit.blit_smooth( + device, queue, canvas.src_view(), hdr_layer_view, &bt, None, + ); + } else { + shared.canvas_blit.blit( + device, queue, canvas.src_view(), hdr_layer_view, &bt, None, + ); + } } } let compositor_layer = lightningbeam_core::gpu::CompositorLayer::new( @@ -1640,44 +1899,10 @@ impl egui_wgpu::CallbackTrait for VelloCallback { ); } - // Render selected DCEL from active region selection (with transform) - if let Some(ref region_sel) = self.ctx.region_selection { - let sel_transform = overlay_transform * region_sel.transform; - lightningbeam_core::renderer::render_vector_graph( - ®ion_sel.selected_graph, - &mut scene, - sel_transform, - 1.0, - &self.ctx.document, - &mut image_cache, - ); - } - drop(image_cache); scene }; - // Render region selection fill into the overlay scene. - // In HDR mode the main scene-building block returns an empty scene (only layer content - // goes through the HDR pipeline), so we must add the selected-DCEL fill here so it - // appears underneath the stipple overlay. In legacy mode the render_dcel call inside - // the block already handled this, but running it again is harmless since `scene` would - // be a fresh empty scene only in HDR mode. - if USE_HDR_COMPOSITING { - if let Some(ref region_sel) = self.ctx.region_selection { - let sel_transform = overlay_transform * region_sel.transform; - let mut image_cache = shared.image_cache.lock().unwrap(); - lightningbeam_core::renderer::render_vector_graph( - ®ion_sel.selected_graph, - &mut scene, - sel_transform, - 1.0, - &self.ctx.document, - &mut image_cache, - ); - } - } - // Render drag preview objects with transparency if let (Some(delta), Some(active_layer_id)) = (self.ctx.drag_delta, self.ctx.active_layer_id) { if let Some(layer) = self.ctx.document.get_layer(&active_layer_id) { @@ -1857,50 +2082,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } } - // 1a. Draw stipple overlay on region-selected graph - if let Some(ref region_sel) = self.ctx.region_selection { - use lightningbeam_core::vector_graph::FillId; - let sel_graph = ®ion_sel.selected_graph; - let sel_transform = overlay_transform * region_sel.transform; - let stipple_brush = selection_stipple_brush(); - let inv_zoom = 1.0 / self.ctx.zoom as f64; - let brush_xform = Some(Affine::scale(inv_zoom)); - - // Stipple fills with visible content - for (i, fill) in sel_graph.fills.iter().enumerate() { - if fill.deleted { continue; } - if fill.color.is_none() && fill.image_fill.is_none() && fill.gradient_fill.is_none() { continue; } - let fill_id = FillId(i as u32); - let path = sel_graph.fill_to_bezpath(fill_id); - scene.fill( - vello::peniko::Fill::NonZero, - sel_transform, - stipple_brush, - brush_xform, - &path, - ); - } - - // Stipple edges with visible stroke - for edge in &sel_graph.edges { - if edge.deleted { continue; } - if edge.stroke_style.is_none() && edge.stroke_color.is_none() { continue; } - let width = edge.stroke_style.as_ref() - .map(|s| s.width) - .unwrap_or(2.0); - let mut path = vello::kurbo::BezPath::new(); - path.move_to(edge.curve.p0); - path.curve_to(edge.curve.p1, edge.curve.p2, edge.curve.p3); - scene.stroke( - &vello::kurbo::Stroke::new(width), - sel_transform, - stipple_brush, - brush_xform, - &path, - ); - } - } - // 1b. Draw stipple hover highlight on the curve under the mouse // During active curve editing, lock highlight to the edited curve if matches!(self.ctx.selected_tool, Tool::Select | Tool::BezierEdit) { @@ -3477,12 +3658,6 @@ impl StagePane { None => return, // No active layer }; - // Revert any active region selection on mouse press before borrowing the document - // immutably, so the two selection modes don't coexist. - if self.rsp_primary_pressed(ui) { - Self::revert_region_selection_static(shared); - } - let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { Some(layer) => layer, None => return, @@ -3496,6 +3671,11 @@ impl StagePane { let point = Point::new(world_pos.x as f64, world_pos.y as f64); + // On a shape-tween in-between frame the displayed geometry is interpolated, not a + // real keyframe — editing it would silently mutate the left keyframe. Lock out + // geometry selection/editing there; clip-instance interaction stays available. + let inbetween = vector_layer.is_tween_inbetween(*shared.playback_time); + // Double-click: enter/exit movie clip editing if response.double_clicked() { // Hit test clip instances at the click position @@ -3535,16 +3715,21 @@ impl StagePane { // Scope this section to drop vector_layer borrow before drag handling let mouse_pressed = self.rsp_primary_pressed(ui); if mouse_pressed { - // VECTOR EDITING: Check for vertex/curve editing first (higher priority than selection) + // VECTOR EDITING: Check for vertex/curve editing first (higher priority than + // selection). Skipped on tween in-between frames — geometry isn't editable there. let tolerance = EditingHitTolerance::scaled_by_zoom(self.zoom as f64); - let vector_hit = hit_test_vector_editing( - vector_layer, - *shared.playback_time, - point, - &tolerance, - Affine::IDENTITY, - false, // Select tool doesn't show control points - ); + let vector_hit = if inbetween { + None + } else { + hit_test_vector_editing( + vector_layer, + *shared.playback_time, + point, + &tolerance, + Affine::IDENTITY, + false, // Select tool doesn't show control points + ) + }; // Priority 1: Vector editing (vertices immediately, curves deferred) if let Some(hit) = vector_hit { match hit { @@ -3581,6 +3766,9 @@ impl StagePane { let hit_result = if let Some(clip_id) = clip_hit { Some(hit_test::HitResult::ClipInstance(clip_id)) + } else if inbetween { + // Geometry not selectable on an in-between frame. + None } else { // No clip hit, test DCEL edges and faces hit_test::hit_test_layer(vector_layer, *shared.playback_time, point, 5.0, Affine::IDENTITY) @@ -3631,8 +3819,16 @@ impl StagePane { } *shared.focus = lightningbeam_core::selection::FocusSelection::ClipInstances(shared.selection.clip_instances().to_vec()); - // If clip instance is now selected, prepare for dragging - if shared.selection.contains_clip_instance(&clip_id) { + // A clip mid motion-tween shows an interpolated position; moving it + // there would silently insert a keyframe and disturb the tween. Allow + // selecting it, but don't start a drag. + let clip_motion_locked = vector_layer + .layer + .animation_data + .is_object_tweened_at(clip_id, *shared.playback_time); + + // If clip instance is now selected (and editable), prepare for dragging + if !clip_motion_locked && shared.selection.contains_clip_instance(&clip_id) { // Store original positions of all selected clip instances let mut original_positions = std::collections::HashMap::new(); for &clip_inst_id in shared.selection.clip_instances() { @@ -4815,6 +5011,7 @@ impl StagePane { _ui: &mut egui::Ui, response: &egui::Response, world_pos: egui::Vec2, + shift_held: bool, shared: &mut SharedPaneState, ) { use lightningbeam_core::tool::{ToolState, RegionSelectMode}; @@ -4828,12 +5025,13 @@ impl StagePane { None => return, }; - // Mouse down: start region selection + // Mouse down: start region selection. Clear the existing selection unless shift + // is held (additive), mirroring the marquee. Region select populates the same + // `Selection` ID sets as every other tool. if self.rsp_drag_started(response) { - // Revert any existing uncommitted region selection, and clear the - // regular selection so both selection modes don't coexist. - Self::revert_region_selection_static(shared); - shared.selection.clear(); + if !shift_held { + shared.selection.clear(); + } match *shared.region_select_mode { RegionSelectMode::Rectangle => { @@ -4904,7 +5102,10 @@ impl StagePane { } } - /// Execute region selection: snapshot DCEL, insert region boundary, extract inside geometry + /// Execute region selection (rect or lasso): cut the geometry along the region + /// outline (an undoable edit) and select the resulting inside sub-pieces into the + /// normal `Selection`. This is the single, unified selection path — there is no + /// separate floating "region selection" anymore. fn execute_region_select( shared: &mut SharedPaneState, region_path: vello::kurbo::BezPath, @@ -4916,8 +5117,8 @@ impl StagePane { let time = *shared.playback_time; - use lightningbeam_core::vector_graph::{EdgeId, FillId, VertexId}; - use std::collections::{HashMap, HashSet}; + use lightningbeam_core::vector_graph::{EdgeId, FillId}; + use std::collections::HashSet; use vello::kurbo::{ParamCurve, Shape as _}; // Convert region path line segments to CubicBez for insert_stroke @@ -4958,179 +5159,134 @@ impl StagePane { return; } - // Do all graph work in a block so the mutable borrow of shared ends - // before we assign to shared.region_selection. - let extraction_result = { - let document = shared.action_executor.document_mut(); - let graph = match document.get_layer_mut(&layer_id) { - Some(AnyLayer::Vector(vl)) => match vl.graph_at_time_mut(time) { + // Cut a copy of the graph along the region outline, then classify which resulting + // pieces lie inside. We work on a clone so the cut can be committed as a single + // undoable action (or discarded entirely if the region caught nothing). + let (graph_before, graph_after, inside_edges, inside_fills) = { + let document = shared.action_executor.document(); + let graph = match document.get_layer(&layer_id) { + Some(AnyLayer::Vector(vl)) => match vl.graph_at_time(time) { Some(d) => d, None => return, }, _ => return, }; - let snapshot = graph.clone(); + let graph_before = graph.clone(); + let mut graph_after = graph_before.clone(); - // Insert region boundary as invisible edges (no stroke style/color) - let region_edge_ids = graph.insert_stroke(&segments, None, None, 1.0); + // Debug capture (set LIGHTNINGBEAM_DUMP_REGION=1): write the exact pre-cut graph + // + lasso segments to a numbered JSON file under the temp dir, so a misbehaving + // region select can be replayed deterministically as a regression test. + if std::env::var_os("LIGHTNINGBEAM_DUMP_REGION").is_some() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static DUMP_N: AtomicUsize = AtomicUsize::new(0); + let n = DUMP_N.fetch_add(1, Ordering::Relaxed); + let seg_pts: Vec<[[f64; 2]; 4]> = segments + .iter() + .map(|c| [[c.p0.x, c.p0.y], [c.p1.x, c.p1.y], [c.p2.x, c.p2.y], [c.p3.x, c.p3.y]]) + .collect(); + if let Ok(graph_json) = serde_json::to_value(&graph_before) { + let dump = serde_json::json!({ "graph": graph_json, "segments": seg_pts }); + let path = std::env::temp_dir().join(format!("lightningbeam_region_dump_{n}.json")); + match serde_json::to_string_pretty(&dump).map(|s| std::fs::write(&path, s)) { + Ok(Ok(())) => eprintln!("[region dump] wrote {}", path.display()), + e => eprintln!("[region dump] failed: {e:?}"), + } + } + } + // Insert the region boundary as invisible edges (no stroke style/color) so any + // shape the region crosses is split into inside/outside sub-fills. + let region_edge_ids = graph_after.insert_stroke(&segments, None, None, 1.0); let region_edge_set: HashSet = region_edge_ids.iter().copied().collect(); - // Classify edges: inside vs outside by midpoint winding - let mut inside_edges = HashSet::new(); - for (i, edge) in graph.edges.iter().enumerate() { + // The lasso outline portions that DON'T cross geometry leave dangling invisible + // edges (no stroke, not part of any fill). Drop them so they can't be selected + // or edited later; the cut edges (now part of a fill boundary) are kept. + graph_after.gc_invisible_edges(); + + // Classify edges: inside by midpoint winding (excluding the cut edges themselves). + let mut inside_edges: Vec = Vec::new(); + for (i, edge) in graph_after.edges.iter().enumerate() { let eid = EdgeId(i as u32); if edge.deleted || region_edge_set.contains(&eid) { continue; } let mid = edge.curve.eval(0.5); if region_path.winding(mid) != 0 { - inside_edges.insert(eid); + inside_edges.push(eid); } } - // Classify fills: inside vs outside by boundary centroid winding - let mut inside_fills = HashSet::new(); - for (i, fill) in graph.fills.iter().enumerate() { + // Classify fills: inside if a guaranteed-interior point of the fill is inside + // the lasso. (A non-convex sub-fill's edge-midpoint average can fall inside the + // lasso even when the shape is mostly outside, so use fill_interior_point.) + let mut inside_fills: Vec = Vec::new(); + for (i, fill) in graph_after.fills.iter().enumerate() { let fid = FillId(i as u32); if fill.deleted { continue; } - let centroid = Self::compute_fill_centroid(graph, fid); - if region_path.winding(centroid) != 0 { - inside_fills.insert(fid); + let probe = graph_after.fill_interior_point(fid); + if region_path.winding(probe) != 0 { + inside_fills.push(fid); } } - // If nothing is inside, restore snapshot and bail - if inside_edges.is_empty() && inside_fills.is_empty() { - *graph = snapshot; - None - } else { - // Extract subgraph (boundary edges get duplicated into both graphs) - let (selected_graph, vtx_map, edge_map) = graph.extract_subgraph( - &inside_edges, - &inside_fills, - ®ion_edge_set, - ); - - // Build boundary maps for merge-back - let boundary_vertex_map: HashMap = vtx_map - .iter() - .filter(|(&old_vid, _)| !graph.vertex(old_vid).deleted) - .map(|(&old, &new)| (new, old)) - .collect(); - - let boundary_edge_map: HashMap = edge_map - .iter() - .filter(|(old_eid, _)| region_edge_set.contains(old_eid)) - .map(|(&old, &new)| (new, old)) - .collect(); - - Some((snapshot, selected_graph, region_edge_ids, boundary_vertex_map, boundary_edge_map)) - } + (graph_before, graph_after, inside_edges, inside_fills) }; - let Some((snapshot, selected_graph, region_edge_ids, boundary_vertex_map, boundary_edge_map)) = extraction_result else { + // Nothing inside: don't mutate the graph (avoid littering it with stray cut + // edges), leaving the selection as cleared/extended on drag-start. + if inside_edges.is_empty() && inside_fills.is_empty() { #[cfg(debug_assertions)] shared.test_mode.clear_pending_geometry(); return; - }; + } - *shared.region_selection = Some(lightningbeam_core::selection::RegionSelection { - region_path: region_path.clone(), + // Record the undo depth before the first cut of this selection session, so a + // later deselect can heal (undo) the cut(s) if nothing was changed in between. + // A shift-additive series of cuts keeps the same base (it accumulates). + if shared.pending_region_cut_base.is_none() { + *shared.pending_region_cut_base = Some(shared.action_executor.undo_depth()); + } + + // Commit the cut as one undoable edit. + let action = lightningbeam_core::actions::ModifyGraphAction::new( layer_id, time, - graph_snapshot: snapshot, - selected_graph, - transform: vello::kurbo::Affine::IDENTITY, - committed: false, - region_edge_ids, - action_epoch_at_selection: shared.action_executor.epoch(), - boundary_vertex_map, - boundary_edge_map, - }); - - shared.selection.clear_geometry_selection(); - - #[cfg(debug_assertions)] - shared.test_mode.clear_pending_geometry(); - } - - /// Compute the centroid of a fill's boundary edge midpoints. - fn compute_fill_centroid( - graph: &lightningbeam_core::vector_graph::VectorGraph, - fid: lightningbeam_core::vector_graph::FillId, - ) -> vello::kurbo::Point { - use vello::kurbo::{ParamCurve, Point}; - let fill = graph.fill(fid); - let mut sum_x = 0.0; - let mut sum_y = 0.0; - let mut count = 0; - for &(eid, _) in &fill.boundary { - if eid.is_none() { - continue; - } - let mid = graph.edge(eid).curve.eval(0.5); - sum_x += mid.x; - sum_y += mid.y; - count += 1; - } - if count > 0 { - Point::new(sum_x / count as f64, sum_y / count as f64) - } else { - Point::ZERO - } - } - - /// Revert an uncommitted region selection, restoring the DCEL from snapshot - fn revert_region_selection_static(shared: &mut SharedPaneState) { - use lightningbeam_core::layer::AnyLayer; - - let region_sel = match shared.region_selection.take() { - Some(rs) => rs, - None => return, - }; - - if region_sel.committed { - // Already committed via action system, nothing to revert + graph_before, + graph_after, + "Region select", + ); + if let Err(e) = shared.action_executor.execute(Box::new(action)) { + eprintln!("Region select cut failed: {}", e); + #[cfg(debug_assertions)] + shared.test_mode.clear_pending_geometry(); return; } - let no_actions_taken = - shared.action_executor.epoch() == region_sel.action_epoch_at_selection; - let no_transform = region_sel.transform == vello::kurbo::Affine::IDENTITY; - - let doc = shared.action_executor.document_mut(); - if let Some(AnyLayer::Vector(vl)) = doc.get_layer_mut(®ion_sel.layer_id) { - if let Some(graph) = vl.graph_at_time_mut(region_sel.time) { - if no_actions_taken && no_transform { - // Fast path: nothing changed, restore from snapshot - *graph = region_sel.graph_snapshot; - } else { - // Delete the main graph's copy of boundary edges - for &eid in ®ion_sel.region_edge_ids { - if !graph.edge(eid).deleted { - graph.free_edge(eid); - } - } - - // Merge the (possibly transformed) selected_graph back - graph.merge_subgraph( - ®ion_sel.selected_graph, - region_sel.transform, - ®ion_sel.boundary_vertex_map, - ®ion_sel.boundary_edge_map, - ); - - // Clean up invisible edges left from the boundary - graph.gc_invisible_edges(); + // Select the inside sub-pieces from the now-current (post-cut) graph. The fill/edge + // ids are stable because the committed graph is exactly `graph_after`. + if let Some(AnyLayer::Vector(vl)) = shared.action_executor.document().get_layer(&layer_id) { + if let Some(graph) = vl.graph_at_time(time) { + for fid in &inside_fills { + shared.selection.select_fill(*fid, graph); + } + for eid in &inside_edges { + shared.selection.select_edge(*eid, graph); } } } - shared.selection.clear_geometry_selection(); + if shared.selection.has_geometry_selection() { + *shared.focus = + lightningbeam_core::selection::FocusSelection::Geometry { layer_id, time }; + } + + #[cfg(debug_assertions)] + shared.test_mode.clear_pending_geometry(); } /// Create a rectangle path centered at origin (easier for curve editing later) @@ -5573,15 +5729,8 @@ impl StagePane { (doc.width as u32, doc.height as u32) }; - // Ensure the keyframe exists before reading its ID. - { - let doc = shared.action_executor.document_mut(); - if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&layer_id) { - rl.ensure_keyframe_at(time, doc_w, doc_h); - } else { - return None; // not a raster layer - } - } + // Don't create a keyframe — explicit "New Keyframe" only. The read below + // returns None (no workspace) if there's no active keyframe to lift from. // Read keyframe id and pixels. let (kf_id, w, h, pixels) = { @@ -5804,6 +5953,9 @@ impl StagePane { kf.raw_pixels[si..si + 4].fill(0); } } + // Punching the hole edits raw_pixels but is NOT committed through an + // action yet — mark dirty so eviction can't drop the un-persisted hole. + kf.dirty = true; } // Re-set selection (commit_raster_floating_now cleared it) and create float. @@ -6012,21 +6164,12 @@ impl StagePane { (doc.width as u32, doc.height as u32) }; - // Ensure the keyframe exists BEFORE reading its ID, so we always get - // the real UUID. Previously we read the ID first and fell back to a - // randomly-generated UUID when no keyframe existed; that fake UUID was - // stored in painting_canvas but subsequent drag frames used the real UUID - // from keyframe_at(), causing the GPU canvas to be a different object from - // the one being composited. - { - let doc = shared.action_executor.document_mut(); - if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&active_layer_id) { - rl.ensure_keyframe_at(*shared.playback_time, doc_width, doc_height); - } - } - - // Now read the guaranteed-to-exist keyframe to get the real UUID. - let (keyframe_id, canvas_width, canvas_height, buffer_before, initial_pixels) = { + // Paint into the ACTIVE keyframe (the one at-or-before the playhead) — + // do NOT create one. Keyframes are made explicitly via "New Keyframe" + // (a new layer already seeds one). If none exists at-or-before the + // playhead, there's nothing to paint into; bail. + let _ = (doc_width, doc_height); + let (keyframe_id, kf_time, canvas_width, canvas_height, buffer_before, initial_pixels) = { let doc = shared.action_executor.document(); if let Some(AnyLayer::Raster(rl)) = doc.get_layer(&active_layer_id) { if let Some(kf) = rl.keyframe_at(*shared.playback_time) { @@ -6036,9 +6179,9 @@ impl StagePane { } else { raw.clone() }; - (kf.id, kf.width, kf.height, raw, init) + (kf.id, kf.time, kf.width, kf.height, raw, init) } else { - return; // shouldn't happen after ensure_keyframe_at + return; // no keyframe at/before the playhead — nothing to paint } } else { return; @@ -6070,7 +6213,7 @@ impl StagePane { self.painting_canvas = Some((active_layer_id, keyframe_id)); self.pending_undo_before = Some(( active_layer_id, - *shared.playback_time, + kf_time, canvas_width, canvas_height, buffer_before, @@ -6078,7 +6221,7 @@ impl StagePane { self.pending_raster_dabs = Some(PendingRasterDabs { keyframe_id, layer_id: active_layer_id, - time: *shared.playback_time, + time: kf_time, canvas_width, canvas_height, initial_pixels: Some(initial_pixels), @@ -6088,7 +6231,7 @@ impl StagePane { }); self.raster_stroke_state = Some(( active_layer_id, - *shared.playback_time, + kf_time, stroke_state, Vec::new(), // buffer_before now lives in pending_undo_before )); @@ -6314,17 +6457,13 @@ impl StagePane { let time = *shared.playback_time; // Canvas dimensions (to create keyframe if needed). - let (doc_w, doc_h) = { + let (_doc_w, _doc_h) = { let doc = shared.action_executor.document(); (doc.width as u32, doc.height as u32) }; // Ensure a keyframe exists at the current time. - { - let doc = shared.action_executor.document_mut(); - if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&layer_id) { - rl.ensure_keyframe_at(time, doc_w, doc_h); - } - } + // Don't create a keyframe — keyframes are made explicitly via "New Keyframe". + // The snapshot below edits the active keyframe and bails if none exists. // Snapshot the pixel buffer before drawing. let (buffer_before, w, h) = { let doc = shared.action_executor.document(); @@ -6667,16 +6806,12 @@ impl StagePane { let time = *shared.playback_time; // Ensure a keyframe exists at the current time. - let (doc_w, doc_h) = { + let (_doc_w, _doc_h) = { let doc = shared.action_executor.document(); (doc.width as u32, doc.height as u32) }; - { - let doc = shared.action_executor.document_mut(); - if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&layer_id) { - rl.ensure_keyframe_at(time, doc_w, doc_h); - } - } + // Don't create a keyframe — keyframes are made explicitly via "New Keyframe". + // The snapshot below edits the active keyframe and bails if none exists. // Snapshot current pixels. let (buffer_before, width, height) = { @@ -6748,16 +6883,12 @@ impl StagePane { let time = *shared.playback_time; // Ensure keyframe exists. - let (doc_w, doc_h) = { + let (_doc_w, _doc_h) = { let doc = shared.action_executor.document(); (doc.width as u32, doc.height as u32) }; - { - let doc = shared.action_executor.document_mut(); - if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&layer_id) { - rl.ensure_keyframe_at(time, doc_w, doc_h); - } - } + // Don't create a keyframe — keyframes are made explicitly via "New Keyframe". + // The snapshot below edits the active keyframe and bails if none exists. let (pixels, width, height) = { let doc = shared.action_executor.document(); @@ -6834,16 +6965,11 @@ impl StagePane { Self::commit_raster_floating_now(shared); // Ensure the keyframe exists. - let (doc_w, doc_h) = { + let (_doc_w, _doc_h) = { let doc = shared.action_executor.document(); (doc.width as u32, doc.height as u32) }; - { - let doc = shared.action_executor.document_mut(); - if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&layer_id) { - rl.ensure_keyframe_at(time, doc_w, doc_h); - } - } + // Don't create a keyframe — explicit "New Keyframe" only; bail below if none. // Snapshot canvas pixels. let (pixels, width, height) = { @@ -9325,6 +9451,20 @@ impl StagePane { return; } + // Block transforming a clip instance that's mid motion-tween (on an in-between + // frame) — it would silently insert a keyframe and disturb the tween. + if is_vector_layer { + let locked = match shared.action_executor.document().get_layer(&active_layer_id) { + Some(AnyLayer::Vector(vl)) => shared.selection.clip_instances().iter().any(|id| { + vl.layer.animation_data.is_object_tweened_at(*id, *shared.playback_time) + }), + _ => false, + }; + if locked { + return; + } + } + let point = Point::new(world_pos.x as f64, world_pos.y as f64); // For video layers, transform the visible clip at playback time (no selection needed) @@ -10583,6 +10723,17 @@ impl StagePane { if !alt_held { use lightningbeam_core::tool::Tool; + // On a shape-tween in-between frame the active vector layer's geometry is an + // interpolated preview, not a real keyframe — lock out the editing tools. + let vector_inbetween = shared.active_layer_id.and_then(|id| { + match shared.action_executor.document().get_layer(&id) { + Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => { + Some(vl.is_tween_inbetween(*shared.playback_time)) + } + _ => None, + } + }).unwrap_or(false); + match *shared.selected_tool { Tool::Select => { let is_raster = shared.active_layer_id.and_then(|id| { @@ -10594,13 +10745,13 @@ impl StagePane { self.handle_select_tool(ui, &response, world_pos, shift_held, shared); } } - Tool::BezierEdit => { + Tool::BezierEdit if !vector_inbetween => { self.handle_bezier_edit_tool(ui, &response, world_pos, shift_held, shared); } - Tool::Rectangle => { + Tool::Rectangle if !vector_inbetween => { self.handle_rectangle_tool(ui, &response, world_pos, shift_held, ctrl_held, shared); } - Tool::Ellipse => { + Tool::Ellipse if !vector_inbetween => { self.handle_ellipse_tool(ui, &response, world_pos, shift_held, ctrl_held, shared); } Tool::Draw => { @@ -10610,7 +10761,7 @@ impl StagePane { }).map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_))); if is_raster { self.handle_unified_raster_stroke_tool(ui, &response, world_pos, &crate::tools::paint::PAINT, shared); - } else { + } else if !vector_inbetween { self.handle_draw_tool(ui, &response, world_pos, shared); } } @@ -10630,20 +10781,20 @@ impl StagePane { Tool::Transform => { self.handle_transform_tool(ui, &response, world_pos, shared); } - Tool::PaintBucket => { + Tool::PaintBucket if !vector_inbetween => { self.handle_paint_bucket_tool(&response, world_pos, shared); } - Tool::Line => { + Tool::Line if !vector_inbetween => { self.handle_line_tool(ui, &response, world_pos, shift_held, ctrl_held, shared); } - Tool::Polygon => { + Tool::Polygon if !vector_inbetween => { self.handle_polygon_tool(ui, &response, world_pos, shift_held, ctrl_held, shared); } Tool::Eyedropper => { self.handle_eyedropper_tool(ui, &response, mouse_pos, shared); } - Tool::RegionSelect => { - self.handle_region_select_tool(ui, &response, world_pos, shared); + Tool::RegionSelect if !vector_inbetween => { + self.handle_region_select_tool(ui, &response, world_pos, shift_held, shared); } Tool::Warp => { self.handle_raster_warp_tool(ui, &response, world_pos, shared); @@ -11571,9 +11722,18 @@ impl PaneRenderer for StagePane { if let Some(layer_id) = target_layer_id { // For images, create a shape with image fill instead of a clip instance if dragging.clip_type == DragClipType::Image { - // TODO: Image fills on DCEL faces are a separate feature. - let _ = (layer_id, world_pos); - eprintln!("Image drag to stage not yet supported with DCEL backend"); + // Image-filled rectangle at the drop point (centered), native size. + let dims = shared.action_executor.document() + .get_image_asset(&dragging.clip_id) + .map(|a| (a.width as f64, a.height as f64)); + if let Some((img_w, img_h)) = dims { + let x = world_pos.x as f64 - img_w / 2.0; + let y = world_pos.y as f64 - img_h / 2.0; + let action = lightningbeam_core::actions::AddShapeAction::image_rect( + layer_id, drop_time, x, y, img_w, img_h, dragging.clip_id, + ); + let _ = shared.action_executor.execute(Box::new(action)); + } } else if dragging.clip_type == DragClipType::Effect { // Handle effect drops specially // Get effect definition from registry or document @@ -11854,6 +12014,9 @@ impl PaneRenderer for StagePane { instance_id: self.instance_id, document: shared.action_executor.document_arc(), tool_state: shared.tool_state.clone(), + onion: shared.onion, + container_path: shared.container_path.clone(), + is_playing: *shared.is_playing, active_layer_id: *shared.active_layer_id, drag_delta, selection: shared.selection.clone(), @@ -11869,7 +12032,6 @@ impl PaneRenderer for StagePane { editing_clip_id: shared.editing_clip_id, editing_instance_id: shared.editing_instance_id, editing_parent_layer_id: shared.editing_parent_layer_id, - region_selection: shared.region_selection.clone(), mouse_world_pos, webcam_frame: shared.webcam_frame.clone(), pending_raster_dabs: self.pending_raster_dabs.take(), @@ -11900,6 +12062,7 @@ impl PaneRenderer for StagePane { .and_then(|(tool, _)| tool.take_pending_gpu_work()), pending_layer_cache_removals: std::mem::take(&mut self.pending_layer_cache_removals), pending_tool_readback_b: self.pending_tool_readback_b.take(), + raster_fault_requests: std::sync::Arc::clone(shared.raster_fault_requests), }}; let cb = egui_wgpu::Callback::new_paint_callback( diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index fbfa360..2d7e5ca 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -99,6 +99,94 @@ fn clip_instance_y_bounds(row: usize, total_rows: usize) -> (f32, f32) { } } +/// Draw the strip of video thumbnails across a clip. +/// +/// Thumbnails are tiled from the clip's **true** (unclamped) screen origin +/// `clip_origin_x` over its full width `clip_true_width_px`, and only the tiles +/// intersecting `visible_rect` are drawn. This makes the strip scroll naturally +/// with the timeline and show the correct content when the clip is partly off the +/// left edge (a tile's content time is derived from its offset from the true +/// origin, not from the clamped visible edge). +/// +/// Each tile's GPU texture is cached by the **actual** thumbnail timestamp (from +/// [`VideoManager::get_thumbnail_at`]), so as closer thumbnails finish generating +/// the strip refreshes instead of freezing on whatever loaded first. +#[allow(clippy::too_many_arguments)] +fn draw_video_thumbnail_strip( + painter: &egui::Painter, + ui: &egui::Ui, + video_mgr: &lightningbeam_core::video::VideoManager, + textures: &mut std::collections::HashMap<(uuid::Uuid, i64), egui::TextureHandle>, + clip_id: uuid::Uuid, + trim_start: f64, + clip_origin_x: f32, + clip_true_width_px: f32, + visible_rect: egui::Rect, + thumb_top_y: f32, + thumb_height: f32, + pixels_per_second: f64, +) { + if clip_true_width_px <= 0.0 || thumb_height <= 0.0 { + return; + } + // Aspect ratio from any available thumbnail; sets the per-tile width. + let aspect = match video_mgr.get_thumbnail_at(&clip_id, 0.0) { + Some((_, tw, th, _)) if th > 0 => tw as f32 / th as f32, + _ => return, + }; + let step = (thumb_height * aspect).max(1.0); + let num_thumbs = ((clip_true_width_px / step).ceil() as usize).max(1); + + // Iterate only the tiles that intersect the visible rect. + let first = (((visible_rect.min.x - clip_origin_x) / step).floor() as i64).max(0) as usize; + let last = (((visible_rect.max.x - clip_origin_x) / step).ceil() as i64).max(0) as usize; + let last = last.min(num_thumbs); + + for i in first..last { + let thumb_x = clip_origin_x + i as f32 * step; + // Content time at the tile centre, measured from the TRUE clip origin so + // it's stable as the clip scrolls. + let content_time = + trim_start + (i as f64 * step as f64 + step as f64 * 0.5) / pixels_per_second; + + let Some((actual_ts, tw, th, rgba_data)) = video_mgr.get_thumbnail_at(&clip_id, content_time) + else { + continue; + }; + let ts_key = (actual_ts * 1000.0) as i64; + let texture = textures.entry((clip_id, ts_key)).or_insert_with(|| { + let image = egui::ColorImage::from_rgba_unmultiplied([tw as usize, th as usize], &rgba_data); + ui.ctx().load_texture( + format!("vthumb_{}_{}", clip_id, ts_key), + image, + egui::TextureOptions::LINEAR, + ) + }); + + let full_rect = egui::Rect::from_min_size( + egui::pos2(thumb_x, thumb_top_y), + egui::vec2(step, thumb_height), + ); + let thumb_rect = full_rect.intersect(visible_rect); + if thumb_rect.width() > 2.0 && thumb_rect.height() > 2.0 { + let uv_min = egui::pos2( + (thumb_rect.min.x - full_rect.min.x) / full_rect.width(), + (thumb_rect.min.y - full_rect.min.y) / full_rect.height(), + ); + let uv_max = egui::pos2( + (thumb_rect.max.x - full_rect.min.x) / full_rect.width(), + (thumb_rect.max.y - full_rect.min.y) / full_rect.height(), + ); + painter.image( + texture.id(), + thumb_rect, + egui::Rect::from_min_max(uv_min, uv_max), + egui::Color32::WHITE, + ); + } + } +} + /// Get the effective clip duration for a clip instance on a given layer. /// For groups on vector layers, the duration spans all consecutive keyframes /// where the group is present. For regular clips, returns the clip's internal duration. @@ -163,6 +251,11 @@ pub struct TimelinePane { /// Vertical scroll offset (in pixels) viewport_scroll_y: f32, + /// Clickable keyframe-diamond hit targets `(screen_rect, keyframe_time)` collected + /// during the last `render_layers`. Used by `handle_input` (next frame) to snap the + /// playhead exactly to a keyframe when its diamond is clicked. + keyframe_diamond_hits: Vec<(egui::Rect, f64)>, + /// Total duration of the animation duration: f64, @@ -685,6 +778,7 @@ impl TimelinePane { pixels_per_second: 100.0, viewport_start_time: 0.0, viewport_scroll_y: 0.0, + keyframe_diamond_hits: Vec::new(), duration: 10.0, // Default 10 seconds is_scrubbing: false, is_panning: false, @@ -2632,18 +2726,21 @@ impl TimelinePane { midi_event_cache: &std::collections::HashMap>, raw_audio_cache: &std::collections::HashMap>, u32, u32)>, waveform_gpu_dirty: &mut std::collections::HashSet, + waveform_minmax_pools: &std::collections::HashMap, target_format: wgpu::TextureFormat, waveform_stereo: bool, context_layers: &[&lightningbeam_core::layer::AnyLayer], video_manager: &std::sync::Arc>, audio_cache: &HashMap>, playback_time: f64, - ) -> (Vec<(egui::Rect, uuid::Uuid, f64, f64)>, Vec) { + ) -> (Vec<(egui::Rect, uuid::Uuid, f64, f32)>, Vec) { let painter = ui.painter().clone(); let mut pending_lane_renders: Vec = Vec::new(); + // Rebuilt each frame; consumed by handle_input (next frame) for click-to-seek. + self.keyframe_diamond_hits.clear(); // Collect video clip rects for hover detection (to avoid borrow conflicts) - let mut video_clip_hovers: Vec<(egui::Rect, uuid::Uuid, f64, f64)> = Vec::new(); + let mut video_clip_hovers: Vec<(egui::Rect, uuid::Uuid, f64, f32)> = Vec::new(); // Track visible video clip IDs for texture cache cleanup let mut visible_video_clip_ids: std::collections::HashSet = std::collections::HashSet::new(); @@ -2899,8 +2996,9 @@ impl TimelinePane { theme.text_color(&["#timeline", ".group-bar"], ui.ctx(), egui::Color32::from_rgb(100, 220, 220)) }; for (s, e) in &merged { - let sx = self.time_to_x(*s); - let ex = self.time_to_x(*e).max(sx + MIN_CLIP_WIDTH_PX); + // `merged` ranges are in beats; convert to seconds for time_to_x. + let sx = self.time_to_x(document.tempo_map().transform(*s)); + let ex = self.time_to_x(document.tempo_map().transform(*e)).max(sx + MIN_CLIP_WIDTH_PX); if ex >= 0.0 && sx <= rect.width() { let vsx = sx.max(0.0); let vex = ex.min(rect.width()); @@ -2940,8 +3038,9 @@ impl TimelinePane { let ci_duration = ci.total_duration(clip_dur, document.tempo_map()); let ci_end = ci_start + ci_duration; - let sx = self.time_to_x(ci_start); - let ex = self.time_to_x(ci_end); + // ci_start/ci_end are in beats; convert to seconds for time_to_x. + let sx = self.time_to_x(document.tempo_map().transform(ci_start)); + let ex = self.time_to_x(document.tempo_map().transform(ci_end)); if ex < 0.0 || sx > rect.width() { continue; } let ci_rect = egui::Rect::from_min_max( @@ -2956,69 +3055,29 @@ impl TimelinePane { egui::pos2(ci_rect.min.x, span_y_min), egui::pos2(ci_rect.max.x, span_y_max), ); - video_clip_hovers.push((hover_rect, ci.clip_id, ci.trim_start, ci_start)); + // 4th elem = clip's TRUE (unclamped) origin x, for correct + // hover content time when scrolled partly off the left. + video_clip_hovers.push((hover_rect, ci.clip_id, ci.trim_start, rect.min.x + sx)); let thumb_display_height = (thumb_y_max - span_y_min) - 4.0; if thumb_display_height > 8.0 { let video_mgr = video_manager.lock().unwrap(); - if let Some((tw, th, _)) = video_mgr.get_thumbnail_at(&ci.clip_id, 0.0) { - let aspect = tw as f32 / th as f32; - let thumb_display_width = thumb_display_height * aspect; - let ci_width = ci_rect.width(); - let num_thumbs = ((ci_width / thumb_display_width).ceil() as usize).max(1); - - for ti in 0..num_thumbs { - let x_offset = ti as f32 * thumb_display_width; - if x_offset >= ci_width { break; } - - let time_offset = (x_offset as f64 + thumb_display_width as f64 * 0.5) - / self.pixels_per_second as f64; - let content_time = ci.trim_start + time_offset; - - if let Some((tw, th, rgba_data)) = video_mgr.get_thumbnail_at(&ci.clip_id, content_time) { - let ts_key = (content_time * 1000.0) as i64; - let cache_key = (ci.clip_id, ts_key); - - let texture = self.video_thumbnail_textures - .entry(cache_key) - .or_insert_with(|| { - let image = egui::ColorImage::from_rgba_unmultiplied( - [tw as usize, th as usize], - &rgba_data, - ); - ui.ctx().load_texture( - format!("vthumb_{}_{}", ci.clip_id, ts_key), - image, - egui::TextureOptions::LINEAR, - ) - }); - - let full_rect = egui::Rect::from_min_size( - egui::pos2(ci_rect.min.x + x_offset, ci_rect.min.y + 2.0), - egui::vec2(thumb_display_width, thumb_display_height), - ); - let thumb_rect = full_rect.intersect(ci_rect); - - if thumb_rect.width() > 2.0 && thumb_rect.height() > 2.0 { - let uv_min = egui::pos2( - (thumb_rect.min.x - full_rect.min.x) / full_rect.width(), - (thumb_rect.min.y - full_rect.min.y) / full_rect.height(), - ); - let uv_max = egui::pos2( - (thumb_rect.max.x - full_rect.min.x) / full_rect.width(), - (thumb_rect.max.y - full_rect.min.y) / full_rect.height(), - ); - - painter.image( - texture.id(), - thumb_rect, - egui::Rect::from_min_max(uv_min, uv_max), - egui::Color32::WHITE, - ); - } - } - } - } + // Tile from the clip's true origin (sx unclamped; ci_rect is + // the clamped visible rect) — scrolls correctly off-screen. + draw_video_thumbnail_strip( + &painter, + ui, + &video_mgr, + &mut self.video_thumbnail_textures, + ci.clip_id, + ci.trim_start, + rect.min.x + sx, + ex - sx, + ci_rect, + ci_rect.min.y + 2.0, + thumb_display_height, + self.pixels_per_second as f64, + ); } } } @@ -3048,8 +3107,17 @@ impl TimelinePane { None => continue, }; - let total_frames = samples.len() / (*ch).max(1) as usize; - let audio_file_duration = total_frames as f64 / *sr as f64; + // Min/max overview pools store 4 f32 per texel at the + // floor rate sr/B; raw pools store interleaved samples. + let minmax_b = waveform_minmax_pools.get(&audio_pool_index).copied(); + let is_minmax = minmax_b.is_some(); + let frame_stride = if is_minmax { 4 } else { (*ch).max(1) as usize }; + let total_frames = samples.len() / frame_stride; + let eff_sr: f32 = match minmax_b { + Some(b) => *sr as f32 / b.max(1) as f32, + None => *sr as f32, + }; + let audio_file_duration = total_frames as f64 / eff_sr as f64; let clip_dur = audio_clip.duration; let mut ci_start = ci.effective_start(); @@ -3058,8 +3126,9 @@ impl TimelinePane { } let ci_duration = ci.total_duration(clip_dur, document.tempo_map()); - let ci_screen_start = rect.min.x + self.time_to_x(ci_start); - let ci_screen_end = ci_screen_start + (ci_duration * self.pixels_per_second as f64) as f32; + // ci_start/ci_duration are in beats; convert to seconds for time_to_x. + let ci_screen_start = rect.min.x + self.time_to_x(document.tempo_map().transform(ci_start)); + let ci_screen_end = rect.min.x + self.time_to_x(document.tempo_map().transform(ci_start + ci_duration)); let waveform_rect = egui::Rect::from_min_max( egui::pos2(ci_screen_start.max(rect.min.x), wave_y_min), @@ -3081,9 +3150,10 @@ impl TimelinePane { } Some(crate::waveform_gpu::PendingUpload { samples: samples.clone(), - sample_rate: *sr, + sample_rate: if is_minmax { eff_sr.round().max(1.0) as u32 } else { *sr }, channels: *ch, frame_limit, + minmax: is_minmax, }) } else { None @@ -3098,7 +3168,7 @@ impl TimelinePane { viewport_start_time: self.viewport_start_time as f32, pixels_per_second: self.pixels_per_second as f32, audio_duration: audio_file_duration as f32, - sample_rate: *sr as f32, + sample_rate: eff_sr, clip_start_time: ci_screen_start, trim_start: ci.trim_start as f32, tex_width: crate::waveform_gpu::tex_width() as f32, @@ -3597,8 +3667,16 @@ impl TimelinePane { // Sampled Audio: Draw waveform via GPU lightningbeam_core::clip::AudioClipType::Sampled { audio_pool_index } => { if let Some((samples, sr, ch)) = raw_audio_cache.get(audio_pool_index) { - let total_frames = samples.len() / (*ch).max(1) as usize; - let audio_file_duration = total_frames as f64 / *sr as f64; + // Min/max overview pools: 4 f32/texel at rate sr/B. + let minmax_b = waveform_minmax_pools.get(audio_pool_index).copied(); + let is_minmax = minmax_b.is_some(); + let frame_stride = if is_minmax { 4 } else { (*ch).max(1) as usize }; + let total_frames = samples.len() / frame_stride; + let eff_sr: f32 = match minmax_b { + Some(b) => *sr as f32 / b.max(1) as f32, + None => *sr as f32, + }; + let audio_file_duration = total_frames as f64 / eff_sr as f64; let screen_size = ui.ctx().content_rect().size(); let pending_upload = if waveform_gpu_dirty.contains(audio_pool_index) { @@ -3620,9 +3698,10 @@ impl TimelinePane { Some(crate::waveform_gpu::PendingUpload { samples: samples.clone(), - sample_rate: *sr, + sample_rate: if is_minmax { eff_sr.round().max(1.0) as u32 } else { *sr }, channels: *ch, frame_limit, + minmax: is_minmax, }) } else { None @@ -3684,7 +3763,7 @@ impl TimelinePane { viewport_start_time: self.viewport_start_time as f32, pixels_per_second: self.pixels_per_second as f32, audio_duration: audio_file_duration as f32, - sample_rate: *sr as f32, + sample_rate: eff_sr, clip_start_time: iter_screen_start, trim_start: preview_trim_start as f32, tex_width: crate::waveform_gpu::tex_width() as f32, @@ -3730,6 +3809,7 @@ impl TimelinePane { sample_rate: *sr, channels: *ch, frame_limit: None, // recording uses incremental path + minmax: false, }) } else { None @@ -3794,75 +3874,31 @@ impl TimelinePane { let thumb_display_height = clip_rect.height() - 4.0; if thumb_display_height > 8.0 { let video_mgr = video_manager.lock().unwrap(); - if let Some((tw, th, _)) = video_mgr.get_thumbnail_at(&clip_instance.clip_id, 0.0) { - let aspect = tw as f32 / th as f32; - let thumb_display_width = thumb_display_height * aspect; - let thumb_step_px = thumb_display_width; - - let clip_width = clip_rect.width(); - let num_thumbs = ((clip_width / thumb_step_px).ceil() as usize).max(1); - - for i in 0..num_thumbs { - let x_offset = i as f32 * thumb_step_px; - if x_offset >= clip_width { break; } - - // Map pixel position to content time - let time_offset = (x_offset as f64 + thumb_display_width as f64 * 0.5) - / self.pixels_per_second as f64; - let content_time = clip_instance.trim_start + time_offset; - - if let Some((tw, th, rgba_data)) = video_mgr.get_thumbnail_at( - &clip_instance.clip_id, content_time - ) { - let ts_key = (content_time * 1000.0) as i64; - let cache_key = (clip_instance.clip_id, ts_key); - - let texture = self.video_thumbnail_textures - .entry(cache_key) - .or_insert_with(|| { - let image = egui::ColorImage::from_rgba_unmultiplied( - [tw as usize, th as usize], - &rgba_data, - ); - ui.ctx().load_texture( - format!("vthumb_{}_{}", clip_instance.clip_id, ts_key), - image, - egui::TextureOptions::LINEAR, - ) - }); - - let full_rect = egui::Rect::from_min_size( - egui::pos2(clip_rect.min.x + x_offset, clip_rect.min.y + 2.0), - egui::vec2(thumb_display_width, thumb_display_height), - ); - let thumb_rect = full_rect.intersect(clip_rect); - - if thumb_rect.width() > 2.0 && thumb_rect.height() > 2.0 { - let uv_min = egui::pos2( - (thumb_rect.min.x - full_rect.min.x) / full_rect.width(), - (thumb_rect.min.y - full_rect.min.y) / full_rect.height(), - ); - let uv_max = egui::pos2( - (thumb_rect.max.x - full_rect.min.x) / full_rect.width(), - (thumb_rect.max.y - full_rect.min.y) / full_rect.height(), - ); - - painter.image( - texture.id(), - thumb_rect, - egui::Rect::from_min_max(uv_min, uv_max), - egui::Color32::WHITE, - ); - } - } - } - } + // Tile from the clip's true origin (start_x is unclamped; + // clip_rect is the clamped visible rect) so the strip scrolls + // correctly and shows the right content when partly off-screen. + draw_video_thumbnail_strip( + &painter, + ui, + &video_mgr, + &mut self.video_thumbnail_textures, + clip_instance.clip_id, + clip_instance.trim_start, + rect.min.x + start_x, + end_x - start_x, + clip_rect, + clip_rect.min.y + 2.0, + thumb_display_height, + self.pixels_per_second as f64, + ); } } - // VIDEO PREVIEW: Collect clip rect for hover detection + // VIDEO PREVIEW: Collect clip rect for hover detection. Store the + // clip's TRUE (unclamped) origin x so the hover content time is + // correct even when the clip is scrolled partly off the left. if let lightningbeam_core::layer::AnyLayer::Video(_) = layer { - video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start, instance_start)); + video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start, rect.min.x + start_x)); } // Draw border per segment (per loop iteration for looping clips) @@ -3944,6 +3980,40 @@ impl TimelinePane { color, egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".keyframe-diamond"], ui.ctx(), egui::Color32::from_rgb(180, 150, 50))), )); + // Record a (slightly enlarged) clickable area for click-to-seek. + self.keyframe_diamond_hits.push(( + egui::Rect::from_center_size(egui::pos2(cx, cy), egui::vec2(13.0, 13.0)), + kf.time, + )); + } + } + } + + // Draw keyframe markers for raster layers (same diamond as vector). + if let lightningbeam_core::layer::AnyLayer::Raster(rl) = layer { + for kf in &rl.keyframes { + let x = self.time_to_x(kf.time); + if x >= 0.0 && x <= rect.width() { + let cx = rect.min.x + x; + let cy = y + LAYER_HEIGHT - 8.0; + let size = 5.0; + let diamond = [ + egui::pos2(cx, cy - size), + egui::pos2(cx + size, cy), + egui::pos2(cx, cy + size), + egui::pos2(cx - size, cy), + ]; + let color = theme.bg_color(&["#timeline", ".keyframe-diamond"], ui.ctx(), egui::Color32::from_rgb(255, 220, 100)); + painter.add(egui::Shape::convex_polygon( + diamond.to_vec(), + color, + egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".keyframe-diamond"], ui.ctx(), egui::Color32::from_rgb(180, 150, 50))), + )); + // Record a (slightly enlarged) clickable area for click-to-seek. + self.keyframe_diamond_hits.push(( + egui::Rect::from_center_size(egui::pos2(cx, cy), egui::vec2(13.0, 13.0)), + kf.time, + )); } } } @@ -4797,6 +4867,30 @@ impl TimelinePane { } } + // Pointing-hand cursor when hovering a clickable keyframe diamond. + if let Some(hover) = response.hover_pos() { + if self.keyframe_diamond_hits.iter().any(|(r, _)| r.contains(hover)) { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + + // Click a keyframe diamond → snap the playhead exactly to that keyframe. + // (Hit targets come from the previous frame's render_layers; diamonds don't + // move between frames, so a click lands on the right one.) + if response.clicked() && !alt_held { + if let Some(pos) = response.interact_pointer_pos() { + if let Some(&(_, kf_time)) = + self.keyframe_diamond_hits.iter().find(|(r, _)| r.contains(pos)) + { + *playback_time = kf_time; + if let Some(controller_arc) = audio_controller { + let mut controller = controller_arc.lock().unwrap(); + controller.seek(kf_time); + } + } + } + } + // Get mouse position relative to content area let mouse_pos = response.hover_pos().unwrap_or(content_rect.center()); let mouse_x = (mouse_pos.x - content_rect.min.x).max(0.0); @@ -5418,7 +5512,7 @@ impl PaneRenderer for TimelinePane { // Render layer rows with clipping ui.set_clip_rect(content_rect.intersect(original_clip_rect)); - let (video_clip_hovers, pending_lane_renders) = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.focus, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.target_format, shared.waveform_stereo, &context_layers, shared.video_manager, &audio_cache, *shared.playback_time); + let (video_clip_hovers, pending_lane_renders) = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.focus, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.waveform_minmax_pools, shared.target_format, shared.waveform_stereo, &context_layers, shared.video_manager, &audio_cache, *shared.playback_time); // Render playhead on top (clip to timeline area) ui.set_clip_rect(timeline_rect.intersect(original_clip_rect)); @@ -5894,26 +5988,24 @@ impl PaneRenderer for TimelinePane { // VIDEO HOVER DETECTION: Handle video clip hover tooltips AFTER input handling // This ensures hover events aren't consumed by the main input handler - for (clip_rect, clip_id, trim_start, instance_start) in video_clip_hovers { + for (clip_rect, clip_id, trim_start, clip_origin_x) in video_clip_hovers { let hover_response = ui.allocate_rect(clip_rect, egui::Sense::hover()); if hover_response.hovered() { if let Some(hover_pos) = hover_response.hover_pos() { - // Calculate timestamp at hover position - let hover_offset_pixels = hover_pos.x - clip_rect.min.x; - let hover_offset_time = (hover_offset_pixels as f64) / (self.pixels_per_second as f64); - let hover_timestamp = instance_start + hover_offset_time; - - // Remap to clip content time accounting for trim - let clip_content_time = trim_start + (hover_timestamp - instance_start); + // Content time from the clip's TRUE origin. `clip_rect` is clamped + // to the viewport, so using its left edge mislabels the frame when + // the clip is scrolled partly off the left (same bug the strip had). + let hover_offset_pixels = hover_pos.x - clip_origin_x; + let clip_content_time = trim_start + (hover_offset_pixels as f64) / (self.pixels_per_second as f64); // Try to get thumbnail from video manager - let thumbnail_data: Option<(u32, u32, std::sync::Arc>)> = { + let thumbnail_data: Option<(f64, u32, u32, std::sync::Arc>)> = { let video_mgr = shared.video_manager.lock().unwrap(); video_mgr.get_thumbnail_at(&clip_id, clip_content_time) }; - if let Some((thumb_width, thumb_height, ref thumb_data)) = thumbnail_data { + if let Some((_, thumb_width, thumb_height, ref thumb_data)) = thumbnail_data { // Create texture from thumbnail let color_image = egui::ColorImage::from_rgba_unmultiplied( [thumb_width as usize, thumb_height as usize], diff --git a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs index 970cd0e..e0f0989 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs @@ -9,6 +9,7 @@ use crate::config::AppConfig; use crate::keymap::{self, AppAction, KeymapManager}; use crate::menu::{MenuSystem, Shortcut, ShortcutKey}; use crate::theme::{Theme, ThemeMode}; +use lightningbeam_core::file_io::LargeMediaMode; /// Which tab is selected in the preferences dialog #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -59,6 +60,7 @@ struct PreferencesState { debug: bool, waveform_stereo: bool, theme_mode: ThemeMode, + large_media_default: LargeMediaMode, } impl From<(&AppConfig, &Theme)> for PreferencesState { @@ -75,6 +77,7 @@ impl From<(&AppConfig, &Theme)> for PreferencesState { debug: config.debug, waveform_stereo: config.waveform_stereo, theme_mode: theme.mode(), + large_media_default: config.large_media_default, } } } @@ -93,6 +96,7 @@ impl Default for PreferencesState { debug: false, waveform_stereo: false, theme_mode: ThemeMode::System, + large_media_default: LargeMediaMode::default(), } } } @@ -567,6 +571,24 @@ impl PreferencesDialog { &mut self.working_prefs.waveform_stereo, "Show waveforms as stacked stereo", ); + ui.horizontal(|ui| { + let threshold_gb = lightningbeam_core::beam_archive::LARGE_MEDIA_THRESHOLD + as f64 + / (1024.0 * 1024.0 * 1024.0); + ui.label(format!("Large media (>{:.0} GB):", threshold_gb)); + let label = |m: LargeMediaMode| match m { + LargeMediaMode::Ask => "Ask each time", + LargeMediaMode::Pack => "Pack into project", + LargeMediaMode::Reference => "Reference external file", + }; + egui::ComboBox::from_id_salt("large_media_default") + .selected_text(label(self.working_prefs.large_media_default)) + .show_ui(ui, |ui| { + for mode in [LargeMediaMode::Ask, LargeMediaMode::Pack, LargeMediaMode::Reference] { + ui.selectable_value(&mut self.working_prefs.large_media_default, mode, label(mode)); + } + }); + }); }); } @@ -629,6 +651,7 @@ impl PreferencesDialog { config.debug = self.working_prefs.debug; config.waveform_stereo = self.working_prefs.waveform_stereo; config.theme_mode = self.working_prefs.theme_mode.to_string_lower(); + config.large_media_default = self.working_prefs.large_media_default; config.keybindings = keybinding_config; // Apply theme immediately diff --git a/lightningbeam-ui/lightningbeam-editor/src/raster_tool.rs b/lightningbeam-ui/lightningbeam-editor/src/raster_tool.rs index d15dad2..8244976 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/raster_tool.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/raster_tool.rs @@ -8,7 +8,7 @@ //! | **B** | Write-only | Output / display. Compositor shows B while the tool is active. | //! | **C** | Read+Write | Scratch. Dabs accumulate here across the stroke; composite A+C→B each frame. | //! -//! All three are `Rgba8Unorm` with the same pixel dimensions. The framework +//! All three are `Rgba16Float` with the same pixel dimensions. The framework //! allocates and validates them in [`begin_raster_workspace`]; tools only //! dispatch shaders. @@ -45,11 +45,11 @@ pub enum WorkspaceSource { /// commit or cancel. #[derive(Debug)] pub struct RasterWorkspace { - /// A canvas (Rgba8Unorm) — source pixels, uploaded at mousedown, read-only for tools. + /// A canvas (Rgba16Float) — source pixels, uploaded at mousedown, read-only for tools. pub a_canvas_id: Uuid, - /// B canvas (Rgba8Unorm) — output / display; compositor shows this while active. + /// B canvas (Rgba16Float) — output / display; compositor shows this while active. pub b_canvas_id: Uuid, - /// C canvas (Rgba8Unorm) — scratch; tools accumulate dabs here across the stroke. + /// C canvas (Rgba16Float) — scratch; tools accumulate dabs here across the stroke. pub c_canvas_id: Uuid, /// Optional R8Unorm selection mask (same pixel dimensions as A/B/C). /// `None` means the entire workspace is selected. diff --git a/lightningbeam-ui/lightningbeam-editor/src/waveform_gpu.rs b/lightningbeam-ui/lightningbeam-editor/src/waveform_gpu.rs index 8195727..1301342 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/waveform_gpu.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/waveform_gpu.rs @@ -112,6 +112,37 @@ pub struct PendingUpload { /// The texture is allocated at full size, but total_frames is set to /// the limited count so subsequent calls use the incremental path. pub frame_limit: Option, + /// When true, `samples` is interpreted as **pre-packed min/max texels**: + /// 4 floats per "frame" = `[l_min, l_max, r_min, r_max]` (a waveform-pyramid + /// floor level). The caller passes the *effective* `sample_rate` (`sr / B`) + /// so the shader's time→texel mapping covers `B` source samples per texel. + /// When false, `samples` is raw interleaved audio (min = max per texel). + pub minmax: bool, +} + +/// Pack one source "frame" into an `Rgba16Float` texel `(Lmin,Lmax,Rmin,Rmax)`. +/// Raw audio sets min = max per channel; min/max input copies the 4 values. +#[inline] +fn pack_texel(samples: &[f32], global_frame: usize, channels: usize, minmax: bool) -> [half::f16; 4] { + if minmax { + let o = global_frame * 4; + [ + half::f16::from_f32(samples.get(o).copied().unwrap_or(0.0)), + half::f16::from_f32(samples.get(o + 1).copied().unwrap_or(0.0)), + half::f16::from_f32(samples.get(o + 2).copied().unwrap_or(0.0)), + half::f16::from_f32(samples.get(o + 3).copied().unwrap_or(0.0)), + ] + } else { + let so = global_frame * channels; + let left = samples.get(so).copied().unwrap_or(0.0); + let right = if channels >= 2 { + samples.get(so + 1).copied().unwrap_or(left) + } else { + left + }; + let (l, r) = (half::f16::from_f32(left), half::f16::from_f32(right)); + [l, l, r, r] + } } /// Maximum frames to convert and upload per frame (~250K frames ≈ 5.6s at 44.1kHz). @@ -291,8 +322,11 @@ impl WaveformGpuResources { sample_rate: u32, channels: u32, frame_limit: Option, + minmax: bool, ) -> Vec { - let new_total_frames = samples.len() / channels.max(1) as usize; + // For min/max input each "frame" is 4 floats; for raw it's `channels`. + let frame_stride = if minmax { 4 } else { channels.max(1) as usize }; + let new_total_frames = samples.len() / frame_stride; if new_total_frames == 0 { return Vec::new(); } @@ -329,22 +363,9 @@ impl WaveformGpuResources { if global_frame >= effective_frames { break; } - let sample_offset = global_frame * channels as usize; - let left = if sample_offset < samples.len() { - samples[sample_offset] - } else { - 0.0 - }; - let right = if channels >= 2 && sample_offset + 1 < samples.len() { - samples[sample_offset + 1] - } else { - left - }; let texel_offset = frame * 4; - row_data[texel_offset] = half::f16::from_f32(left); - row_data[texel_offset + 1] = half::f16::from_f32(left); - row_data[texel_offset + 2] = half::f16::from_f32(right); - row_data[texel_offset + 3] = half::f16::from_f32(right); + let t = pack_texel(samples, global_frame, channels as usize, minmax); + row_data[texel_offset..texel_offset + 4].copy_from_slice(&t); } let entry = self.entries.get(&pool_index).unwrap(); @@ -466,24 +487,9 @@ impl WaveformGpuResources { for frame in 0..seg_upload_count as usize { let global_frame = seg_start_frame as usize + frame; - let sample_offset = global_frame * channels as usize; - - let left = if sample_offset < samples.len() { - samples[sample_offset] - } else { - 0.0 - }; - let right = if channels >= 2 && sample_offset + 1 < samples.len() { - samples[sample_offset + 1] - } else { - left - }; - let texel_offset = frame * 4; - mip0_data[texel_offset] = half::f16::from_f32(left); - mip0_data[texel_offset + 1] = half::f16::from_f32(left); - mip0_data[texel_offset + 2] = half::f16::from_f32(right); - mip0_data[texel_offset + 3] = half::f16::from_f32(right); + let t = pack_texel(samples, global_frame, channels as usize, minmax); + mip0_data[texel_offset..texel_offset + 4].copy_from_slice(&t); } // Upload mip 0 (only rows with actual data) @@ -701,6 +707,7 @@ impl egui_wgpu::CallbackTrait for WaveformCallback { upload.sample_rate, upload.channels, upload.frame_limit, + upload.minmax, ); cmds.extend(new_cmds); }