Skip to content

release: v1.4.5 - #268

Merged
theunavailableguy merged 133 commits into
masterfrom
release/v1.4.5
Aug 27, 2026
Merged

release: v1.4.5#268
theunavailableguy merged 133 commits into
masterfrom
release/v1.4.5

Conversation

@AIEraDev

Copy link
Copy Markdown
Owner

Release v1.4.5

Summary of Changes

🔤 Native GPU SDF Text Engine & Font Registry

  • Native Signed Distance Field (SDF) text rendering: Migrated text rendering from browser canvas to GPU-accelerated wgpu/WGSL SDF shaders for razor-sharp typography at any scale.
  • Native font registry & WOFF2 support: Added register_native_font / register_native_font_bytes IPC commands; native core automatically decodes bundled WOFF2 and TTF fonts via woff2-patched.
  • Per-glyph font fallback & Noto Emoji: render_text_sdf_aligned_with_fallback seamlessly resolves missing glyphs to the bundled @fontsource/noto-emoji fallback font.
  • Synthetic weight & italic transforms: Pre-SDF bitmap dilation and skewing synthesize bold and italic styles for single-weight fonts.
  • Contract v2 text snapshots: Extended native contract to v2, introducing text runs for timed karaoke/captions, vertical alignment, and multi-pass text shaders.
  • Native text templates & compound clip reuse: Text templates instantiate natively with full styling and shader passes preserved across export and preview.
  • Retired browser text rasterization: Desktop preview and export render text layers directly via wgpu, eliminating canvas raster roundtrips and DOM bottlenecks.

🔊 Professional Audio Engine & Timeline Controls

  • Extended native audio mixer: Rust mixer supports fade-in / fade-out curves, keyframe interpolation, pan, channel routing, and pitch preservation.
  • Interactive audio envelope editor: CapCut-style volume rubber bands and fade handle knobs directly on clips with cubic Bézier curves.
  • J/L-cut audio unlinking: Added UnlinkAudioCommand and RelinkAudioCommand for independent audio/video trimming and J/L cut workflows.
  • Atomic audio graph updates: replaceNativeAudioClips IPC synchronizes audio edits in-place without restarting playback decoders.
  • Desktop Web Audio engine: Browser desktop audio path honors clip-level preservePitch with smooth time-stretching.
  • Waveform fade curves & solo button: Waveforms render overlaid fade curves with source-scoped peak caching and per-track solo controls.

🔄 Session-Safe Deferred Updates & Auto-Updater Pipeline

  • Two-stage update lifecycle: Separated update download from installation via AutoUpdateManager singleton; active editing sessions are never interrupted.
  • Save-before-update verification: Transport playback is paused and project state is atomically saved and verified before update restart; save failures abort installation safely.
  • Unified updater state: Synchronized update notifications, downloading status, and restart prompts across SettingsModal and UpdateBanner.

💾 Project Persistence Reliability & Crash Recovery

  • Atomic save pipeline: Verified candidate write (.tmp), generation rotation (.bak), and atomic file replacement with save receipt verification.
  • Transactional project hydration: validateAndMigrateProjectPayload safely migrates legacy project fields; hydration failures roll back to the previously active project.
  • Failure-isolated recent projects: Corrupt or unreadable projects no longer hide valid projects and offer one-click recovery from verified backup files.
  • Crash recovery snapshot v2: IndexedDB snapshot persists timeline gaps, markers, and schema version.
  • Cross-platform save hardening: Resolved Windows backup rename races and mobile Capacitor overwrite/rename paths.

🖥️ Dual-Monitor Workspace & Preview Layouts

  • Dual-monitor preview workspace: Added PreviewMonitorWorkspace supporting configurable side-by-side row and stacked column orientations for Source and Program monitors.
  • Independent preview panels: PreviewPanel supports an explicit mode prop ("program" vs "source") regardless of global preview state.
  • Interaction-deferred transport context: SourcePreview defers transport claiming until user interaction, preventing accidental playback hijacking on mount.

🎨 Design System, Semantic Tokens & Clip Palettes

  • Per-theme clip palettes: Clip colors derive from dynamic semantic design tokens (--clypra-clip-*) mapped 1-to-1 with UI themes.
  • Full palette fidelity: Eliminated heavy CSS saturation and opacity filters that dulled theme palette colors.
  • Live theme preview swatches: Settings theme swatches preview actual built-in clip palette color schemes.

📐 Timeline Viewport Precision & Interactions

  • Sub-pixel alignment & 20px clip-start offset: Consistent timeline coordinates across Ruler, Clips, Playhead, Gaps, and mouse/wheel seek anchors via TIMELINE_CLIP_START_OFFSET_PX.
  • Refined visual hierarchy: Distinct A-roll (primary) and B-roll visual roles with dedicated track heights.
  • Visual track ordering guards: Visual tracks are prevented from being placed below the main video track, enforced across drag/drop and history undo/redo.
  • CapCut-style ruler: Clean ticks with compact MM:SS formatting under one hour and HH:MM:SS timecodes for longer projects.
  • Spring-animated zoom & canonical overview: Buttery-smooth anchored wheel zoom with spring physics; projects reliably open at standard minimum zoom.

⚡ Performance, Telemetry & Lookahead Pre-fetching

  • Zero-PII performance telemetry: Lightweight client and collector reporting stage timings and dropped frames with adaptive sampling.
  • Bounded lookahead pre-fetching: Predictive decoding warms upcoming clip assets within a 3-second horizon without saturating GPU presentation.
  • Event-driven paused render loop: Native preview pauses RAF loops while stopped, waking instantaneously on state and transform changes.
  • 24-track compositor density: Validated multi-track layer pooling and composition stability up to 24 concurrent tracks.

🛠️ UI Polish & Workflow Shortcuts

  • Collapsible properties panel: 44px collapsed rail with expand button and shortcut icons; Alt+P / Cmd+Shift+P toggle shortcut.
  • Media library shortcut: Cmd+B toggles the media library drawer.
  • Transform overlay polish: RAF-throttled gizmo tracking, enlarged hit targets, and diagonal resize cursors.

Verification

  • Full TypeScript type check passed cleanly (tsc --noEmit).
  • Documentation link check passed cleanly (npm run docs:check).
  • Rust test suite passed (cargo test --manifest-path src-tauri/Cargo.toml).
  • Rust clippy checks passed cleanly with zero warnings (cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings).
  • Vitest suite passed cleanly across 265 test suites and 2151 tests.
  • Production build compiled successfully (npm run build).

…ady top-to-bottom

Root cause: the audio-mix export path applied vflip to compositor frames via
[0:v]vflip[v] in the FFmpeg filter_complex. The video-only path applied -vf
vflip. Both were carried over from an earlier WebGL readPixels path, which
produces bottom-left RGBA and requires a vertical flip. The wgpu/Metal
compositor readback that replaced it produces standard top-to-bottom RGBA,
so the flip inverted an already-correct frame, producing upside-down exports.

export.rs:
- Audio-mix path: [0:v]vflip[v] → [0:v]null[v]. The null filter is a
  zero-cost passthrough that preserves the [v] label used by subsequent
  -map [v] stream selection.
- Video-only path: -vf vflip removed entirely.
- Comments updated to reflect native compositor readback orientation.
…order

adapter.ts (toCompositorClip):
- Read clip.zIndex when it is a finite number and use it directly rather than
  falling back unconditionally to trackIndex. This preserves same-track
  stacking order that was explicitly set (e.g. via the Z-order UI) and
  survived serialization, so reloading a project cannot silently reorder
  clips just because the array arrived in a different order.
- Clips created before explicit zIndex was stored still fall back to
  Math.max(0, trackIndex), maintaining backward compatibility.

zorder.test.ts:
- New test 'preserves persisted same-track zIndex independent of clip array
  order': two overlay clips on the same track arrive with high (zIndex=10)
  before low (zIndex=5) in the array; the evaluator must produce low beneath
  high regardless of insertion order.
trackPropertyActions.ts (new):
- toggleTrackPropertyWithHistory(trackId, property): the one user-intent
  boundary for lock/mute/solo/visible. Guards locked tracks from mute/solo
  changes, then dispatches ToggleTrackPropertyCommand through useHistoryStore
  so every entry point produces the same state transition, undo/redo record,
  cache invalidation, and persistence path.

TrackCommands.ts (ToggleTrackPropertyCommand):
- property union extended to include 'solo', so the command covers all four
  toggleable track att  toggleable track att  toggleable track att  toggleable tracus  toggleable track att  toggleable track Labe  toggleable track att  toggleable track att  toggleable rou  toggleable track att  toggleable track hods d  toggleable track att  toggleable track att  toggleailit  toggleable track att  toggleable track att  n; dead store variable removed.
- useKeyboardS- useKeyboardS- useKeyboardS- useKeybo+V (visibility), and
  Cmd+Alt+M (mute) shortcuts use the shared action.

commands.test.ts:
- AddTrackCommand fixture changed fro- AddTrackCommand fixture changed fro- AddTrackCommand fixture changed it- AddTrackCommand fixture changed fro- AddTrackCommand fixture changed pertyCommand for 'solo' sets track.solo=true.
…ss preview and export

nativeRasterBridge.ts (new):
- NativeRasterBridge: instance-scoped class that produces and uploads immutable
  native raster assets for DOM-compatible constructs (Studio text, gradient/
  shader backgrounds, Lottie stickers, smart overlays) without introducing a
  browser compositor fallback. Each preview session and export job owns a
  bounded cache and disposes DOM resources via dispose().
- rasterize(scene, {frameKey}): parallelises text, background, and animated-
  sticker rasterization into a single awaitable that returns
  NativeRasterLayerSnapshot[] for use by buildNativeVideoProjectRequest.
- rasterizeSmartOverlays(...): re- rasterizeSmartOverlays(...): re- raste  - rasterizeSmartOverlays(...): re- rasterizeSmartOverled - rasterizeSmartOverlays(...): re- rasterizeSmartOverlaysti- rasterizeSmartOverlays(...):hout- rasterizeSmartOverlays(...): re- ch- rasterizeSmartOverlays(...): yId at- rasterizeSmartOverlays(...): re- rasterizeSmartOverlays(...): re- raste  - rasterizeSmartOverlays(...): re- rastert failures, not silent fallbacks- rasterizeSmartOverlays(...): re- rasterizeSmartOverlays(...): re- raste  - rveAnimatedStickers,
  rasterizeNativeBackground, and rasterizeNativeSmartOverlays functions
  removed (~220 lines). Replaced by a single NativeRasterBridge instance
  shared across the component's render loop, using bridge.rasteri  shared across the component's render loop, using bridpat  shared across the component's render loop, using bridge.rasteri  shared .ts  shared across the component's render loop, using bridge.rge  shared across the component's render loop, using bridge.rasteri  sharses in a finally block.
  Text, gradient backgrounds, Lottie stickers, and smart overlays now render
  identically in single-frame export, image-sequence export, and video export
  as they do in native preview.
…ginal track

Root cause: expandCompoundClips() overwrote every child's trackId with the
parent compound's trackId, collapsing the multi-track layout back to a single
track on every evaluation, export, and audio-mix pass.

compoundClips.ts (expandCompoundClips):
- Remove the trackId: clip.trackId override on child clips. Children already
  carry their original trackId from GroupClipsCommand; expand() now passes
  them through unmodified. Legacy same-track compounds are unaffected because
  their children and parent share the same trackId anyway.

CompoundClipCommands.ts:
- validateGroupSelection: remove the single-track assertion; multi-track grou- validateGroupSelection: remove the single-track assertion;  o- validateGroupSelection: remove the ti- validateGroupSelection: remove the single-track asme- validateGroupSelection: remove the single-track assertion; multi-or- validateGroupSelection: remove the single-tramm- validateGroupSelection: remove the single-track assertion; multi-track grou- validateGroupSelection: remart- validateGroupSelection: remove the single-track assertion; multi-track kI- validateGroupSelection: remove the single-track assertion; multi-track groapse on undo/redo.

Tests:
- 'groups cross-track clips whi- 'groups cross-track clips whi- 'grou
  expansion and ungroup': validates GroupClipsCommand + expandCompoundClips +
  expansion and ungroup': validates GroupClipsCommand + expandCompoundClies  expansion and ungroup': validates GroupClipsCommand + expandCompoundCliecros  expansion and ungroup': validates GroupClipsCommand + s re  expansion and ungron.test.ts:
- New test 'preserves cross-track compound child topology through project
  serialization': a compound with a video child and an audio child round-trips
  through toRustClip/fromRustClip with trackIds intact.
…4 tracks

contracts.rs:
- New test request_validation_enforces_video_and_raster_layer_caps: verifies
  that exactly 256 video layers and 64 raster layers are accepted by the native
  contract validator, and that adding one more layer beyond each cap returns an
  error containing the expected human-readable message. Tests the overflow
  boundary for both caps rather than just asserting the limit constant.

multi_track_compositor_tests.rs:
- test_16_track_density_stress renamed to test_24_track_density_stress.
- Layer count raised from 16 to 24 to match the product acceptance target
  (20+ simultaneous tracks). Opacity adjusted to 1/24 for consistent additive
  blend coverage. Color stride changed from *16 to *10 to avoid wrapping at 24
  layers. Native run c  layers. Native run c  layers. Native run c  layers. Native ceptance hardware.
…t-class Clip fields

types/index.ts:
- ClipRole type moved here from compositor/types.ts so it is a persisted Clip
  field, not a runtime-only compositor concept.
- Clip interface gains three optional fields: role (ClipRole), zIndex (number),
  and evaluationPriority (number), each with a doc comment stating its
  semantics. All three are optional for backward compatibility.

compositor/types.ts:
- ClipRole definition removed; re-exported from types/index.ts to preserve
  the existing public surface of the compositor module.

adapter.ts (toCompositorClip):
- clip.role accessed directly without the (clip as any) cast.
- clip.zIndex accessed directly without the intermediate persistedZIndex cast.
- clip.evaluationPriority read from the typed field; falls back to 0 - clip.evaluationPriority read from the typed field; falls baex- clip.evaluationPrioit- clip.evaluationPriority ren round-trip because they are now first-class Clip fields.
  trackIndex is still excluded (derived from timeline order, not stored).

items.test.ts:
- New ass-rtion: toCompos- New ass-rtion: toCompos- New ass-rtion: toCompos- New ass-rtion: toCompos-nput.
… resolver

ordering.ts (new):
- compareCompositorClips: the single bottom-to-top sort contract for all
  composited frames. Ordering rules in priority sequence:
  1. Role band: background (0), normal visual content (1: primary/overlay/text),
     effect (2). Audio is -1 (no visual contribution).
  2. Track index descending: higher trackIndex draws first/below; lower
     trackIndex draws last/on top, matching the editor's top-row-is-foreground
     convention. This applies to all normal visual roles equally — primary,
     overlay, and text no longer have hidden numeric bands that override user-
     visible track order.
  3. zIndex ascending: tie-breaks clips on the same track.
  4. evaluationPriority ascending: stable final tie-breaker.
  5. Clip ID lexicographic: dete  5. Clip ID lexicographic: dete  ga  5. Clip ID lexicographic: detor  5. Clip ID lexicographic: dete  Co  5. Clip ID lexicographic: dete  5. Cit  5. Clip ID lexicographic: dete  5. Clip ID lexicographic: dete  ga  5. Clip ID lexicographic: detpareRenderLayers (47 lines) and getRoleOrder deleted; replaced by a
  one-liner that delegates to co  one-liner that delegates to co  one-liner that delempara  one-liner that delegates to co  one-liner that delegates to co  one-liner trCli  one-liner that delegates to co  one-liner that orderi  one-liner that delegates to co  one-liner that delegates to co  one-
  retain structural bands.
…ng contract

compositor.test.ts:
- Three-clip layer order test: primary and text clips now sort by track index
  (text at higher track index draws before primary at lower index), not by
  role band. Expected order updated from [bg, primary, text] to [bg, text, primary].

zorder.test.ts:
- Describe block renamed from 'Role-Based Sorting (Primary Concern)' to
  'Timeline-Owned Visual Stacking' to reflect the contract.
- 'sorts clips by role: primary before overlay before text' renamed and
  expectations inverted: the text clip is now at visualLayers[0] (lowest
  editor track, draws first) and primary at [2] (top editor track, draws last).
- 'places overlay role above primary role regardless of track positio- 'places overlay role above primary role regardless of trackop- 'places overlay role above primary role regardleson- 'places overlay role above primary role regarol- 'places overlay role above primary role regole-- 'places overlay role above primary role regardless of track positio- 'places overlay role above primary gle- 'places overla [te- 'places overlay role above primary role regardless of track positio- 'placle-based grouping.
effectiveAudioState.test.ts:
- New test 'keeps hidden tracks audible until the separate audio policy mutes
  or solos them': asserts isTrackAudible returns true and evaluateEffective-
  AudioState produces muted=false for a track whose visible flag is false but
  whose muted and solo flags are unset. Visibility is a compositor concern;
  hiding a video track must not silently remove its audio contribution.

trackPropertyActions.test.ts:
- New test 'keeps rapid visibility changes reversible one command at a time':
  three consecutive toggleTrackPropertyWithHistory calls produce three
  independent history entries. Undo and redo each step deterministically,
  verifying that no coalescing or debounce mechanism collapses them and that
  each   each   each   each   each   each   each   each   each   eac
…nversion

Root cause: FFmpeg can preserve matrix=rgb and transfer=unspecified in the
VideoColorMetadata even after swscale has converted a still-image frame from
packed RGB to NV12. The native preview compositor validates incoming frame
metadata against a set of supported YUV color spaces; a frame tagged as RGB
after NV12 conversion fails that check and produces a diagnostic overlay
instead of the image.

normalize_converted_nv12_color(mut color):
- Applied at both NV12 extraction sites in VideoDecoder::decode_still_image_nv12
  (direct NV12 path and swscale path) immediately before the tuple is returned.
- When matrix == rgb: matrix is set to bt709/AVCOL_SPC_BT709.
- If transfer was unspecified: set to srgb/AVCOL_TRC_IEC61966_2_1.
- If primaries were unspecified: set to bt709/AVCOL_PRI_BT709.
- If range was unspecified: set to full/AVCOL_RANGE_JPEG.
- Non-RGB metadata (genuine YUV sources) is returned unchanged.

Defaults are explicit BT.709+sRGB SDR values that match what the native
compositor expects for full-range NV12 still-image content.

Tests:
- rgb_still_image_metadata_becomes_native_sdr_nv12_metadata: matrix=rgb input
  produces bt709/srgb/bt709/full output.
- already_supported_yuv_metadata_is_preserved: bt601_625/bt709/limited input
  is returned byte-for-byte identical.
… reload

Root cause: normalizeClipTiming() derived sourceDuration from resolveClipDuration(asset)
for all clip types. For still images, resolveClipDuration returns the insertion-time
default (5 s). When a user extends an image clip on the timeline (e.g. to 64 s),
that authored duration is stored as clip.duration and clip.trimOut. On reload,
normalizeClipTiming clamped both values back to 5 s, making the clip appear to
have moved or been truncated.

timelineClip.ts (normalizeClipTiming):
- sourceDuration is now Infinity for asset.type === 'image', matching the
  treatment of clips without a resolved asset. Still images are durationless
  at the source level; the timeline duration is the canonical user-authored
  state and must not be overridden by the asset's default duration.
- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Vid- Viwith startTime=13.6, duration=64, trimOut=64 and a
  canvas transform (x=407.37, width=1105.26) backed by an asset with
  duration=5 round-trips through normalizeClipTiming without any field
  being clamped or overwritten.
…l-image decode

media.rs:
- decode_image_rgba(path, width, height): Tauri command that decodes a still
  image into an exact-size RGBA8 buffer using the image crate, bypassing the
  FFmpeg/NV12 video decoder path. Returns a raw binary response so the JS
  caller receives an ArrayBuffer with no base64 overhead.
- Validates dimensions against the native limit (1–8192 per axis) before
  spawning the blocking decode task.
- decode_image_rgba_bytes: synchronous inner function. Uses Lanczos3 resize
  only when the decoded dimensions differ from the requested target.
- Test: native_still_image_decode_preserves_alpha_and_exact_dimensions —
  decodes public/clypra.png to 37×23 (arbitrary non-natural size), verifies
  byte count is exactly 37*23*4, and asserts at least one pixel has alpha<255.

lib.rs: decode_image_rgba registered as a Tauri command.
…payload

serialization.ts:
- RustProject gains optional main_video_track_id field (camelCase alias
  mainVideoTrackId handled by validateAndMigrateProjectPayload).
- validateAndMigrateProjectPayload: reads main_video_track_id from the raw
  payload and surfaces it on both the snapshot and the rustProject so the
  timeline store receives it during hydration.
- toRustProject: writes main_video_track_id when the caller supplies it
  via options.mainVideoTrackId; absent otherwise so old serialized projects
  do not gain a spurious null field.
- ProjectPersistenceSnapshot: gains optional mainVideoTrackId field.

serialization.test.ts:
- Existing round-trip test extended with main_video_track_id in the input
  and an assertion that snapshot.mainVideoTrackId === 'track-1'.
…VideoTrackId

Root cause: image overlay tracks are serialized as type='video' (the track
type is a display category, not a media-kind discriminator). On legacy project
load, hydrateFromProject took the first video-typed track as the main row.
When an image overlay track appeared above the video track, it was promoted to
mainVideoTrackId and normalizeTrackOrderForMainVideo moved the real video
track below it, corrupting the timeline layout.

timelineStore.ts (hydrateFromProject):
- Three-level main track resolution:
  1. Explicit: payload.mainVideoTrackId present and the track exists — use it.
  2. Inferred: first video-typed track that has at least one clip whose kind
     is 'video' or whose media asset type is 'video'. This reliably picks th     is 'video' or whose media asset type is 'video'. This reje     is 'video' or whose medieo     is 'video' or whose media asset type ispr     is 'video' or whose media  l     is 'video' or whose media asset type is 'video'. This reliably picks th     is 'video' or whose media
- captureCurrentProjectSnapshot reads mainVideoTrackId from the timeline
  st  st  st  st  st  st  st  st  st  st  st  st  st  st  st  st  st  st  st  st ain row explicitly.
- loadProject passes payload.mainVideoTrackId into hydrateFromProject.

timelineStore.test.ts:
- New test 'does not promote an image ove- New test 'does not promote an image ove- New test 'does not promote anag- New test 'does not promote an image ove- Newoad- New test 'does not promote an imageId- New test 'does not promote an imarack-video-main' and track order is unchanged.
… path

models/mod.rs:
- Project struct gains main_video_track_id: Option<String> with #[serde(default)]
  so existing saved projects without the field deserialize cleanly.

App.tsx:
- loadProject call passes normalized.mainVideoTrackId into the hydrateFromProject
  payload so the value from the persistence layer reaches the timeline store.
…ed RGBA8 rasters

NativeRasterBridge:
- imageCache: Map<string, Promise<number[]>> — keyed by a stable serialization
  of sourcePath + dimensions. Pixel data is cached independently from position,
  scale, and opacity so moving or resizing an image on the timeline does not
  re-decode the source file.
- rasterizeImages(scene): filters media layers whose mediaType is 'image' and
  stickerFormat is neither 'gif' nor 'lottie'; calls decodeNativeRgbaFrame for
  each unique (path, width, height) key; builds an UploadableNativeRaster with
  the layer's x/y/rotation/opacity/zIndex/blendMode forwarded through.
  assetId is 'native-image:{layerId}:{pixelKey}' so the compositor can locate
  it by layer identity.
- rasterize() now awaits rasterizeImages in paral- rasterize() now awaits rasterizeImages in paral- rasterize(che.

nativeRasterBridge.test.ts:
- New test- New test- New test- New test- New test- New test- New teas- New test- New test- New test- New test- New test- New test- New teas- New test- New test- New test- New test-cac- New test- New tehot fields, moved position,
  and that register receives the correct rgba array.
…e on raster availability

nativeVideoPreview.ts:
- isNativeVideoGraphLayer(layer): new predicate — true for video media and GIF
  stickers only. Static-image and PNG sticker layers are now explicitly excluded
  from the YUV video graph.
- isSupportedNativeVideoLayer: delegates to isNativeVideoGraphLayer instead of
  the previous (mediaType==='video' || mediaType==='image') check.
- buildNativeVideoProjectRequest: separates imageLayers from mediaLayers.
  Returns null (native not ready) when any image layer is present but its
  native-image raster asset has not yet been registered. This ensures the
  compositor never receives an image via the NV12 video path.
- getNativePreviewBlockers: reports 'Still image {layerId} is waiting for its
  alpha-preserving native raster frame.' for each u  alpha-preserving native raster frame.' for each y iden  alpha-preserving native raster frame.' for each u  alpha-preserving nar   alpha-preserving native raster frame.' for each u  alpha-preserving native raster frame.' for each y iden  alpha-presage  alpha-preserving naot  alpha-preserving native raster frame.' for each u  alpha-preserving native:
- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '-  lay- '- '- '- '- '- 'ss the ras- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- ot- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- 'igurati- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- '- 'ildNativeVideoProjectRequest; getNativePreviewBlockers
  contains the expected wai  contains the expected wai  contains the expected wai  contains the expd   contains the expected wai  contains the expected wquest built with layers=[] and rasterLayers
  containing the raster; getNativePreviewBlockers returns [].
…ortcut icons

Root cause: collapsing the Properties Panel set its width to 0px with no rail
or re-entry affordance, making the panel permanently inaccessible until reload.

PropertiesPanel.tsx / EmptyPropertiesState.tsx:
- Collapsed state renders a 44px-wide rail (matching the left Media Sidebar).
- Header shows a ChevronLeft expand button when collapsed, ChevronRight
  collapse button when expanded.
- Collapsed rail shows a vertical stack of shortcut icon buttons (clip type,
  text, animations, audio, transform, color grade, canvas/background) that
  call onToggleCollapse to expand the panel immediately.
- className prop added so cal- className prop added so cal- className prop added so cal- c(c- className prop added so cal- className prd). aria-hidden
  removed; the panel is interactive in both states.

EditorLayout.tsx:
- All layout presets (default, tall-player-right, tall-player-left,
  dual-player, cinema-preview, vertical-shorts, inspector-focus) pass
  width={propertiesPanelCollapsed ? 44 : propertiesW} so the cent  width={propertiesPanelCollapsed ? 44 : propertiesW} so the cent  widthout a   width={propertiesPanelCollapsed ? 44 : propertiesW} so the cent  width={pr veri  width={propertiesPanelCollapsed ? 44 : prope,
  and that the expanded 44px rail width and expand button are rendered when
  collapsed.
- PropertiesPanel: verifies collapsed state width and expand button.
… properties panel

TopBar.tsx:
- PanelLeft button: toggles Media Library (sidebarCollapsed). Accent-tinted
  when expanded, muted when collapsed. Tooltip shows ⌘B shortcut.
- PanelRight button: toggles Properties Panel (propertiesPanelCollapsed).
  Same active/inactive styling. Tooltip shows ⌥P shortcut.
- A 1px divider separates the panel toggles from the layout/settings group.
- Both buttons are no-drag regions with explicit cursor: pointer.
native_preview.rs (register_native_image_asset):
- New Tauri command that decodes a still image and uploads its RGBA pixels
  directly into the native GPU texture cache without routing the pixel buffer
  through the WebView IPC boundary. Eliminates the multi-megabyte JSON/IPC
  stall that occurred on first-use image activation during playback.
- Calls decode_image_rgba_bytes then get_or_upload_rgba_layer_to_texture.
  The native GPU cache owns the decoded raster; JS receives only an Ok.

media.rs: decode_image_rgba_bytes made pub(cramedia.rs: decode_image_rgba_bytes made pu regmedia.rs: decode_image_rgba_bytered media.rs: decode_image_urmedia.rs: decode_image_reAssetmedia.rs: decode_image_rgba_bytes made pa
media.rs: decode_image_rgba_bytes made pub(cramedia.rs:Brmedia.rs: decode_image_rgba_bytes ls regismedia.rs: decode_image_rgba_bytes made pub(crameddemedia.rs: decode_image_rgba_bytes made pub(cramedia.rs:Brmolmedia.rs: decode_image_rgba_bytes made pub(cramedia.rs:Brmedia.rs: decode_image_rgba_bytegister()media.rs: decode_image_rgba_bytes made pub(c recomedia.rs: decode_image_rgba_bytes made pub(cramedia.rs:Brmedia.rs: decode the
  source in both the layer ID and the key).

nativeAudioTimeline.ts:
- NativeAudioTimelineOptions with preserveTransportPitch flag.
- toNativeAudioTimelineClip sets preservePitch = true when the transport
  option is set, so scrubbing at non-1× speed does not pitch-shift preview
  audio regardless of the per-clip setting.

nativeAudioPreviewController.ts: passes preserveTransportPitch: true to
  syncNativeAudioTimeline so the preview authority always preserves pitch.

Tests:
- nativeAudioTimeline.test.ts: carries the fade edit through the adapters;
  preserveTransportPitch overrides the per-clip flag.
- nativeRasterBridge.test.ts: updated mocks and assertions for the new
  in-GPU registration path.
…and shaders

font_registry.rs:
- FontRegistry: a global parking_lot::RwLock registry that maps font IDs to
  loaded fontdue::Font instances. DEFAULT_FONT_ID ('default') is pre-seeded at
  startup. Fonts are registered by name and looked up by ID at render time.
  global_font_registry() returns the process-singleton.

glyph_cache.rs:
- GlyphSdfCache: per-font, per-size SDF glyph atlas. Each entry stores the
  normalised SDF bitmap, advance width, and bearing metrics needed by the
  text-layer renderer.
- ShapedTextSdf: the result of shaping a complete text string — a list of
  (SdfGlyph, position) pairs ready for the GPU upload path.
- TextAlign enum (Left / Center / Right) used by the layout engine.
- global_glyph_cache() returns the process-singleton.

sdf.rs:
- generate_sdf(bitm- generate_sdf(bitm- generate_sdf(bitm- generis- generate_sdf(bitm- generate_sdf(bitmsk- generate_sdf(bitm- generate_sdf(bitm- geneol- generate_sdf(bitm- generate_sdf(bitm- generate_sdf(bitm- generis- generate_sdf(bitm- generate_sdf(bitmskide
             uting the SDF so glyphs with strokes or glows do not clip at
  their ink boundary.

Shaders (wgsl):
- sdf_distance_threshold.wgsl: sharp-edged SDF fill render pass.
- sdf_outline.wgsl: SDF-based stroke with configurable width and color.
- sdf_outline.wgsl: SDF-based stroke with configurable an- sdf_outline._drop_shadow.wgsl: SDF-based drop shadow with offset and blur.
effect_interpreter.rs:
- EffectDefinition / ParamSpec / ParamType: declarative effect schema shared
  by the runtime and the property panel. Each effect declares its parameter
  names, types (Float/Color/Vec2), and valid ranges.
- validate_effect_definition: asserts all param specs are well-formed at
  load time so runtime errors are confined to malformed effect registrations.
- PrimitivePass / ResolvedPass: typed render-pass description produced by
  resolve_passes() from an effect instance and its overrides.
- ResolutionTier enum selects the GPU quality path (Fast/Balanced/Quality).
- sanitize_parameter_overrides: clamps all float params to their declared
  range, coerces NaN/Inf to defaults, and rejects unknown keys.
- param_f32 / param_co- param_f32 / param_co- param_f32 / param_co- param_f3.
- param_f32 / param_co- param_f32 / param_co- param_ the f- param_f32 / param_co- param_f32 / param_co- param_ ne, - param_f32 / param_co- param_f32 / param_co- param_ the f- param_f32 / param_co- param_f32 / ompiled- param_f32 / param_cms- param_f32 / param_co- param_f32 / param_co- param_ the f- param_f32 / pararams): GPU-compatible uniform layouts for each pass.
- render_pass(): records the appropriate pipeline dispatch into a command
  encoder, ready for the compositor's frame-level submit call.

text_layer_ctext_layer_ctext_layer_ctext_layer_ctext_layer_ctext_layer_ctext_layer_ctexteytext_layer_ctext_layer_ctext_layer_ctext_layer_ctext_layer_ctext_layer+ eftext_layer_ctext_layer_ctext_layer_ctext_layer_ctext_layea cactext_layer_ctext_layer_ctext_layer_ctext_layer_ctext_layer_ctext_layer_ctealtext_layer_ctext_layer_ctext_layer_ctext_layer_ctext_layer_ctext_layer_CACHE_BYTES (256 MiB) with LRU eviction on overflow.
…, and rollback

Root cause: history commands apply their result via useTimelineStore.setState
directly. This is correct — commands produce a complete immutable state — but
it bypasses the auto-save middleware that wraps timeline store mutations, so
property edits made from the Properties panel (TransformClipCommand,
UpdateClipCommand, etc.) updated the live timeline and preview without ever
triggering a save.

scheduleProjectSaveAfterTimelineMutation(): calls
useProjectStore.getState().scheduleAutoSave() after every direct setState.
Wrapped in try/catch so isolated tests and pre-startup uses of the history
store cannot fail a command when the project store is not yet ready.

Called after: execute, undo, redo, and rollbackTransaction — all four paths
that write to the tithat write to the tithat write to the tithat write to the tithat write to the tithat write to the tithat write to the tithat write to the tithat write to the tithat write to the tithat write to the tithat write to the tithat write to the tithat write  Muthat write to the tithat write to the tithat write to the tithat write to th was called exactly once.
…e on state changes

Root cause: the native render loop called scheduleNextFrame unconditionally at
the end of every frame, keeping a permanent RAF running even during paused
playback. Every timeline edit, text change, or property update triggered a
full RAF chain even when the compositor had nothing new to render.

wakeNativeRenderLoopRef: a stable useRef that exposes the scheduleNextFrame
function to React's effect system. Set when the render loop initialises and
cleared on cleanup so wake calls after unmount are a no-op.

hasPendingVisualChange guard: scheduleNextFrame is only called at the end of
a frame when at least one of these conditions is true — clock is playing,
forceRenderNeeded, clips/tracks/transitions/project reference changed, or the
frame index changed. Paused frames with no pendiframe index changed. Paused frames with no pendiframe index changed. Paused frames with no pendiframe index changed. Paused frames with no pendiframe index changed. Paused frames with no pendiframe index changed. Paused frames with ed frame index changed. Paused frames with no pendiframe index changed. Paused f frame instead of an idle loop.
…clip re-renders on text edit

The memoized ClipInner component compared only timing and position fields.
Editing a text clip's content in the Properties panel updated the store but
the timeline preview kept showing the old string because arePropsEqual
returned true.

Added comparisons for clip.kind, clip.name, and clip.text (for text clips)
so any content change breaks the equality check and triggers a re-render.
…ed fonts on desktop

On Tauri desktop the font picker now filters GOOGLE_FONTS to those in
getBundledNativeFontIds() so users only see fonts the wgpu renderer can
actually render. System fonts (OS-level) are hidden on desktop because they
are not registered with the native font registry.

selectedFontIsUnavailableNatively: when the clip already uses a font not in
the native set, a disabled option labelled '<font> (not supported by native
preview)' is inserted at the top of the list so the current value remains
visible and the user understands why it may look wrong.

A helper text below the select on desktop reads: 'Desktop preview supports
the bundled native fonts shown here.'
Root cause: Track used React.memo and its arePropsEqual function compared only
timing, position, and volume fields. A text edit in the Properties panel
updated the clip in the store but the memo comparison returned true, so the
Track never re-rendered its children and ClipInner never received the updated
text — even after the Clip.tsx fix.

Added prevClip.kind, prevClip.name, and prevClip.text to the per-clip equality
check so any content change breaks the memo and propagates into ClipInner.

Track.test.tsx regression: 'updates a text clip label immediately when its
text changes' — renders a Track with text: 'Abdulkabir Musa', rerenders with
text: 'Abd', and asserts the old text is gone and the new text is present.
…eduplicated toast

The blocking overlay panel covered the entire preview area and required a
state re-render cycle to show/hide. A repeated native frame error (e.g. a
missing font or unsupported effect primitive during playback) set nativeOnlyBlocked
each frame, thrashing React state.

Replaced with toast.error / toast.dismiss using a stable id
'native-only-preview-blocked'. The toast is shown once when the blocker set
changes and dismissed automatically when the scene clears. Removed the three
useState / useRef variables (nativeOnlyBlocked, nativeOnlyBlockers,
nativeOnlyBlockedRef) and the full overlay JSX block.
…back font

NATIVE_EMOJI_FONT_ID = '__clypra_noto_emoji': a stable internal ID that never
appears in the font picker (it is not in BUNDLED_FONTS / assetByFontId).

ensureNativeFontsRegistered: now always registers the Noto Emoji face in
parallel with the primary font IDs, so the Rust glyph cache has the emoji
fallback available before any frame is queued. The registration is cached by
registerBundledFont so IPC is issued at most once per session.
…font fallback

render_text_sdf_aligned_with_fallback: new method that accepts an optional
fallback (font, hash) pair. For each character, it calls
font.lookup_glyph_index(ch) on the primary font; if the result is 0 (missing
glyph) and the fallback font has the glyph, the fallback is used for that
character only. All other characters continue to use the selected font.

render_text_sdf_aligned: simplified to a forwarding call with fallback = None,
preserving the existing API for callers that do not need fallback.

This keeps the user's chosen typography authoritative while allowing emoji and
other supplementary code points to remain visible using the Noto Emoji fallback
registered by nativeFontRegistry. The fallback renders in SDF monochrome using
the layer's text color — multicolor CBDT/SBIX glyph tables are not used.
…_with_fallback

get_or_render_text_layer: after resolving the primary font, attempts to load
EMOJI_FALLBACK_FONT_ID from the registry via require_font (ok() so a missing
emoji face is not a hard error). The fallback reference is forwarded to
render_text_sdf_aligned_with_fallback so emoji and supplementary code points
not covered by the selected font render using Noto Emoji glyphs instead of
being silently skipped.
…s move surface

Root cause: the move surface div on the selected clip intercepted all mouse
events, so clicking an active text or image layer that overlaps the selected
video always started a drag on the video instead of changing selection.

getHitTestCandidates(clips, tracks, time, x, y): extracted from the overlay
mousedown handler as a pure, exportable function. Returns active clips at a
canvas point sorted topmost-first (lower track index = visually higher). Used
by both the full-overlay click handler and the new move-surface handler.

handleMoveSurfaceMouseDown: intercepts the move surface's onMouseDown. Before
starting a drag it runs getHitTestCandidates and checks whether a different
clip is visually above the selecteclip is visually above the selecteclip is visually above the  sclip is visually above the selecteclip is visualventing
tttttttttttttttttttttttttttttttttttttttttttttttttttttttttMouseDown(e, 'movetttttttttttttttttttttttttttttttttttttttttttttttttttttttttMouseDown(e, 'movettttttttttttttttttttttttttttttttttcate inline logic is removed.

TransformOverlay.test.ts:
- New describe block 'TransformOverlay hit testing'.
- Test 'returns the visually topmost active la- Test 'returns the visually topmost active la- Test 'returs, one inactive clip, one hidden-track clip; asserts
  getHitTestCandidates returns [text, image] in compositor order.
…and Variable suffix

Root cause: normalizeFontFamily appends 'Variable' to family stacks and
resolves CSS stacks (e.g. 'Dancing Script' → 'Dancing Script Variable'), but
the font select used that normalized string as the controlled value. The
<select> had no option with value 'Dancing Script Variable', so the dropdown
showed a blank selection.

resolveFontPickerValue(family): new exported helper that searches
FONT_PICKER_OPTIONS four ways in priority order — exact raw match, normalized
match, label match, Variable-suffix-stripped match — and falls back to the
normalized family when no option matches. This makes the controlled value
always point to a real <option> in the list.

selectedFont now uses resolveFontPickerValue(requestedFont) so the picker
reflects the actual stored alias (e.g. 'Dancing Script') instead of an
unseen normalizeunseen normalizeunseen normalizeunseen normalizeunseen normalizeunseen nobounseen normalizeunseen normalizeunseen normalizeunseen normalizeunseen normalizeunseen nobounseen normalizeunscript'.unseen normalizeunseen normalizeunseen normalizeunseen normalizeunseesysunseen normalizeunseen normalizeunseen normalizeunseen normalizeunseen normaans'.
…forms before SDF

fontdue exposes only a single font face and cannot produce weight or italic
variants. This change applies deterministic bitmap post-processing before
SDF generation so the compositor matches the CSS-level style controls.

glyph_cache.rs:
- style_bitmap(bitmap, w, h, weight, italic): synthesizes bold by dilating
  filled pixels 1–3 px depending on the weight value, and italic by
  horizontally shearing the bitmap by ~22% of glyph height.
- get_or_insert_styled(font, hash, ch, size_px, radius, padding, weight,
  italic): incorporates weight and italic into the cache key via XOR hashing
  so every (glyph × size × weight × italic) combination is cached separa  so every (glyph × size × weight × italic) combination isli  so every (glyph × size × weight × italic) nd  so every (glyph × size × weight × itali
  pe  pe  pe  pe  pe  pe  pe  pe  pe  pth  pe  pe  pe  pe  pe  pe  pe  pe  pe  pth on  pe  pe  pe  pe  pe  pe  pe  pe  pe  pth  pe  pe  pe  pe  pe  pe  pe  pe  pe  pth on  pe  pe  pe  pe  pe  pe  t_weight strings ('bold', 'semibold', numeric strings) to u16.
- Derives italic- Derives italic- Derives italic- Derives italic- Derives italic- Deriveac- Derives italic- Derives italic- Derives italic- Derives italico the native SDF rasterizer.

nativeFontRegistry.ts: comment updated to reflect that weight/style are
applied by the Rust rasterizer rather than being separate font files.

sdf_tests.rs: new test 'styled_glyphs_are_cached_separatsdf_tests.rs: new test 'styled_glyphs_are_cached_separatsdf_tests.rs: nrisdf_tests.rs: new test 'styled_glyphs_are_cached_separatsdf_tests.rs: new tt least regular width.
…ultiline height

measureTextInk now accepts fontWeight (string | number), fontStyle, and
lineHeight. Text is split on newlines and each line is measured independently;
heights accumulate with the lineHeight multiplier across all three code paths
(success, no-context fallback, catch).

measureTextEffectContentBounds and calculateTextClipSize updated to pass
fontStyle through. wrappedLineCount and textHeight calculations fixed to count
explicit newlines before applying wrap math, preventing double-counting.

Tests:
- 'measures explicit multiline text using the requested line height': two-line
  text with lineHeight:2 produces more height than lineHeight:1.
- 'keeps empty text ink empty': empty string returns zero width and zero height.
…uard

The 'preset-neon' entry is removed from DEFAULT_PRESETS. A custom persist
merge function filters out any rehydrated preset with id === 'preset-neon'
so users who have it in localStorage are silently migrated without losing
custom presets. Test fixture updated: empty presets initial state, count
assertion adjusted from 2 to 1.
…fore placement

Root cause: the native font stack (emoji fallback, synthetic italic, bold
dilation) can produce a texture wider than the browser's CSS estimate. Without
scale correction the texture overflowed the text box.

compute_text_layer_scale(layer, shaped_w, shaped_h):
- Returns 1.0 when box_width/box_height are absent or degenerate.
- Otherwise computes min(box_w/shaped_w, box_h/shaped_h) clamped to
  [0.0001, 1.0] — scale-down only, never up.

Both render_native_video_project_frame_bytes_timed and present_native_frame
apply the scale to produce display_width/display_height, then pass the scaled
dimensions to compute_text_layer_placement so centering remains correct after
the texture is rescaled.
… long JSX lines

Gradient color picker trigger: triggerClassName gains w-28 h-8 min-w-0
overflow-hidden with deep child selectors ([&>div]:min-w-0 max-w-full
overflow-hidden, [&>div>span]:min-w-0 truncate whitespace-nowrap) so long
gradient value strings are clipped inside the fixed-width trigger button
instead of overflowing the properties panel.

The rest of the diff is Prettier reformatting of long JSX attribute strings
to multi-line; no logic changes.
…hile visible decode in flight

Two concurrent prefetch jobs contended with surface presentation and
reintroduced playback stutter on constrained hardware. The horizon is now
capped at currentFrame + 3s worth of frames so only nearby boundaries are
warm-started, and a new nativePlaybackInFlight guard prevents any prefetch
from launching while a visible frame decode is active. Candidate set reduced
from 24 to 1 (nearest boundary only).
@theunavailableguy
theunavailableguy merged commit 46c237f into master Aug 27, 2026
1 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants