release: v1.4.5 - #268
Merged
Merged
Conversation
…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 [].
…very re-register filter
…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.
…gles Properties Panel
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.
… use nullish coalescing
…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.
…compositor lookup
…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.
… now owns the section wrapper
…st path (deferred commit)
…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
approved these changes
Aug 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release v1.4.5
Summary of Changes
🔤 Native GPU SDF Text Engine & Font Registry
register_native_font/register_native_font_bytesIPC commands; native core automatically decodes bundled WOFF2 and TTF fonts viawoff2-patched.render_text_sdf_aligned_with_fallbackseamlessly resolves missing glyphs to the bundled@fontsource/noto-emojifallback font.🔊 Professional Audio Engine & Timeline Controls
UnlinkAudioCommandandRelinkAudioCommandfor independent audio/video trimming and J/L cut workflows.replaceNativeAudioClipsIPC synchronizes audio edits in-place without restarting playback decoders.preservePitchwith smooth time-stretching.🔄 Session-Safe Deferred Updates & Auto-Updater Pipeline
AutoUpdateManagersingleton; active editing sessions are never interrupted.SettingsModalandUpdateBanner.💾 Project Persistence Reliability & Crash Recovery
.tmp), generation rotation (.bak), and atomic file replacement with save receipt verification.validateAndMigrateProjectPayloadsafely migrates legacy project fields; hydration failures roll back to the previously active project.🖥️ Dual-Monitor Workspace & Preview Layouts
PreviewMonitorWorkspacesupporting configurable side-by-side row and stacked column orientations for Source and Program monitors.PreviewPanelsupports an explicitmodeprop ("program" vs "source") regardless of global preview state.SourcePreviewdefers transport claiming until user interaction, preventing accidental playback hijacking on mount.🎨 Design System, Semantic Tokens & Clip Palettes
--clypra-clip-*) mapped 1-to-1 with UI themes.📐 Timeline Viewport Precision & Interactions
TIMELINE_CLIP_START_OFFSET_PX.MM:SSformatting under one hour andHH:MM:SStimecodes for longer projects.⚡ Performance, Telemetry & Lookahead Pre-fetching
🛠️ UI Polish & Workflow Shortcuts
Alt+P/Cmd+Shift+Ptoggle shortcut.Cmd+Btoggles the media library drawer.Verification
tsc --noEmit).npm run docs:check).cargo test --manifest-path src-tauri/Cargo.toml).cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings).npm run build).