Skip to content

Sync develop into staging#261

Merged
walterlow merged 37 commits into
stagingfrom
develop
May 28, 2026
Merged

Sync develop into staging#261
walterlow merged 37 commits into
stagingfrom
develop

Conversation

@walterlow
Copy link
Copy Markdown
Owner

@walterlow walterlow commented May 28, 2026

Summary

Rolls up 36 commits from develop into staging. Spans new editor features, an export quality-preset workflow, a large audio/timeline performance pass, and a TimelineItem hook-composition refactor.

Features

  • Caption auto-styling, per-item task progress, subtitle-embed gating (editor)
  • One-click quality presets in the export dialog (export)

Performance

  • Audio decode moved off the main thread in phases — full decode, playback-window decode, worker-persisted bins, off-thread bin assembly; plus faster WAV encode / Int16↔Float32 conversion and skipped redundant conform decode on clip revisit
  • Timeline zoom — defer filmstrip/waveform mount and render at settled zoom to kill zoom-out jank; skip off-screen clip interiors via content-visibility; cut forced reflows during scroll/zoom; skip redundant filmstrip disk re-hydration on remount
  • Hot-path cleanups — eliminate O(N) scans in selectors/store reads, consolidate redundant store subscriptions, Math.sqrt over Math.hypot in waveform mixing

Refactors

  • TimelineItem hook composition — extracted caption dialog, fade editors, fade math, edit-preview shifts, visual bounds, smart-trim hover, and context-menu state into dedicated hooks; split EdgeHalos / TransitionDropGhost into memo'd children
  • items-store split into normalize + indexes modules (with normalization test coverage)
  • i18n — dissolve missing.json fallback into per-feature partials; replace remaining placeholder strings in en locale

Fixes

  • Close perf-mark buffer leak and clip-content zoom defer race
  • Prevent preview shift when entering path/pen edit mode
  • Restore behavior regressed by the TimelineItem hook extraction

Test plan

  • Export dialog quality presets produce expected resolution/codec output
  • Caption auto-styling and subtitle-embed gating behave correctly
  • Timeline zoom in/out stays smooth; filmstrips/waveforms render sharp on settle
  • Audio playback/scrub unaffected by off-thread decode changes
  • No regressions in clip trim/fade/transition editing

Summary by CodeRabbit

  • New Features

    • Default caption style preset in Settings; quick export presets (Max/Recommended/Balanced/Small); expandable background-task progress with per-item details
  • Improvements

    • Off-main-thread audio decoding for smoother playback/export; smarter timeline rendering (better filmstrip caching, viewport handling, deferred visuals during zoom); improved subtitle embedding detection and export UI
  • Localization

    • Added translations for caption styling and export UI

Review Change Stack

walterlow added 30 commits May 25, 2026 22:21
Replaces title-cased placeholder fragments in media.embeddedSubtitles,
media.picker, media.subtitleScan, media.transcribe, and timeline track
hints with real English copy, matching the structure used by other
locales (including interpolation tokens).
Match the Pen Tool and Path Edit toolbar height to the combined
alignment + playback row height (60px) so the preview canvas no longer
reflows when toggling edit modes.
items-store.ts grew to 1746 lines mixing the Zustand store with audio EQ
normalization, frame-field rounding, derived index builders, and subtitle
cue helpers. Extract:

- items-store-normalize.ts: normalizeFrameFields, normalizeItemUpdates,
  normalizeTrack, subtitle cue trim helpers, roundFrame helpers.
- items-store-indexes.ts: buildItemsByTrackId, buildItemById,
  buildLinkedItemsByItemId, withItemIndexes, buildRippleShiftByItemId,
  getTransitionLinkedIds, and supporting predicates.

items-store.ts (now 897 lines) contains the store factory plus its
media-dependency and in/out-point side-effect subscriptions. No behavior
or public API changes — useItemsStore remains the only export and is
imported unchanged by all 65 consumers.
normalizeFrameFields and normalizeItemUpdates each repeated ~30 nearly
identical conditional clamps for the 9 EQ bands plus audio fades, pitch,
and frame fields. Replace the duplicated code with EQ_BANDS + a single
OPTIONAL_FIELD_CLAMPS table, then iterate it once per call. Adding a new
band or audio field is now a single entry instead of two parallel edits.

items-store-normalize.ts: 533 -> 347 lines.
… items

TimelineItem subscribed to several preview stores 3-7 times each, once per
field. Each subscription is a separate Zustand listener that re-runs on every
store mutation (slide drag, rolling edit, effect drop drag fire many per
second). Collapse the multi-field reads into single useShallow snapshots:

- Slide preview: 7 -> 1 (isPrimary / isLeftNeighbor / isRightNeighbor /
  slideDelta / minDelta / maxDelta / primary-side neighbor ids)
- Rolling edit preview: 3 -> 1 (delta / handle / constrained)
- Effect drop preview: 3 -> 1 (isSingle / isMulti / hoveredMultiCount)
- Media library: drop a separate mediaFileName subscription that did its
  own mediaItems.find() scan; derive it from the already-subscribed
  mediaForItem record. Eliminates one O(n) array scan per render.

Net: 46 -> 35 store subscriptions per TimelineItem. isLinkedSlideCompanion
moves from a slide-store selector (which read items-store inside) to a
useMemo over the consolidated slidePreview snapshot - same firing cadence,
one fewer subscription.

No behavior change; full timeline test suite (648/648) and lint pass.
The recent refactor that collapsed audioEq band clamping into a data-driven
table had no dedicated tests — only six EQ/fade/crop mentions across 803
lines of items-store.test.ts. A typo in the EQ_BANDS table (wrong freq
range, missing hasGain, wrong qDefault) would slip through.

Add focused unit tests for items-store-normalize covering:

- roundFrame / roundDuration / roundOptionalFrame / normalizeOptionalFps
  edge cases (NaN, infinity, negatives, fallback)
- normalizeFrameFields: frame field rounding, source/trim rounding,
  legacy split sourceStart inference, EQ gain/Q/frequency clamping,
  cut slope snapping, enabled-flag boolean coercion, shape mask blend
  mode override
- normalizeItemUpdates: defined-only mutation, legacy sourceEnd backfill,
  no spurious field insertion
- normalizeTrack: volume clamp range, undefined passthrough
- trimSubtitleCuesAtStart / End: drop/truncate/rebase semantics

30 tests, all green.
…ler from TimelineItem

Pull the caption/transcription surface (~165 lines) out of the
TimelineItem god component into a dedicated hook + child component:

- use-caption-dialog-state.ts owns the dialog state machine, transcript
  selectors, embedded-subtitle extraction, and per-cue consolidation.
- transcribe-dialog-controller.tsx renders the TranscribeDialog from
  the hook output and is memo-gated by canManageCaptions.

Host file shrinks from 3,794 to 3,588 lines and the caption logic is
now isolated from the rest of the clip-render path.
Pull the four parallel fade-edit state machines (video fade, audio fade,
audio fade curve, audio volume) plus their refs, commit effects, and
~500 lines of pointer handlers out of TimelineItem into a single hook.

The audio volume CSS-variable preview stays imperative (no React state
on the drag hot path), and all four state machines preserve their
isCommitting cycle that auto-clears once persisted values match the
preview.

index.tsx shrinks from 3,588 to ~2,800 lines.
Move the ~180 lines of fade-ratio, curve-point, curve-path, and audio
volume visualization derivations from TimelineItem into a pure-math hook
that takes the displayed* values from useFadeEditors plus a few other
primitives.

The hook is gated by skipFadeComputation so narrow inactive clips still
skip the expensive curve-path computations.

index.tsx shrinks by another ~120 lines.
… useEditPreviewShifts

Move the six per-item preview-store subscriptions (linked-edit, rolling,
ripple, track-push, slip, slide) plus the slide neighbor lookups into a
single hook. The subscriptions still use the same useShallow selectors,
so per-clip re-renders only fire when this item's participation in the
active gesture changes — same as before — but the timeline-item host
file no longer has 200 lines of inline preview-store boilerplate.

index.tsx shrinks by another ~190 lines.
…mBounds

Move the leftFrame/rightFrame computation, the contentPreviewItem
derivation chain (trim/roll/slide/slip with source-frame adjustments),
and the visualLeftFrame/visualWidthFrames calculation into a single
hook that takes the preview shifts and trim/stretch state.

Source-frame conversions, slide-continuity math, and isCompactWidth
calculation all live in the hook now.
…over

Move hoveredEdge / smartTrimIntent / smartBodyIntent state machines,
the activeTool-change reset effect, the mouse-move intent resolution,
and handleMouseLeave into a hook scoped to the TimelineItem.

The smartTrimIntentRef is still exposed so pointer handlers
(handleClick, handleMouseDown, handleSmartTrimStart) can read the
latest intent at gesture-start without re-render churn.
Move closerEdge state + handleContextMenu out of TimelineItem into a
small focused hook. The larger pointer handlers (handleClick,
handleMouseDown, handleDoubleClick, handleSmartTrimStart) stay in the
host file because they are tightly coupled to local trim/stretch/slip
state and consume too many host-level dependencies to extract without
introducing churn in their dep arrays.

Pull-out is intentionally minimal here: the bigger sub-component split
in the next change does the rest of the de-coupling work.
…'d children

Extract the active edge halos block and the transition drop ghost into
small memo'd sub-components. Both take narrow, primitive props and will
now skip render work when their state hasn't changed.

The rest of the JSX (audio/video fade overlays, ClipContent, ClipIndicators,
trim/stretch/fade/volume handles) is still inline in the host file —
those sub-component splits are best done together with their handler hooks
and are left as follow-up work.
- Add shared DEFAULT_PROJECT_WIDTH/HEIGHT/FPS constants and replace
  hardcoded 1920/1080/30 fallbacks across timeline, editor, export,
  media-library, projects, and runtime modules
- Extract FULLSCREEN_QUAD_WGSL into gpu-shared and dedupe the
  fullscreen-quad vertex shader across 6 GPU pipeline modules
- Extract filmstrip cache config constants and FilmstripMetricsAccumulator
  into sibling modules (filmstrip-cache: 2725 -> 2603 lines)
- Extract useDopesheetViewport and useElementSize hooks from
  dopesheet-editor (index.tsx: 3028 -> 2955 lines)
- Expand gpu-transitions and gpu-effects test coverage with registry
  contract, alignment, and defensive-input checks (16 -> 30 tests)
…tion

- use-caption-dialog-state: stop gating canExtractEmbeddedSubtitles on
  canManageCaptions so linked audio (MKV/WebM) regains the Extract
  Embedded Subtitles menu entry
- use-edit-preview-shifts: zero slideDelta for items unrelated to the
  active slide so useShallow comparison passes across mousemove ticks,
  eliminating an O(N) per-frame re-render storm during slide drags
- use-element-size: stop resetting size to {0,0} on disable so the
  graph pane keeps its last observed dimensions across visualization-
  mode toggles, avoiding a zero-sized flash on re-enter
Three cleanup findings from the prior audit that were initially deferred:

- Extract the OPTIONAL_FIELD_CLAMPS table to shared/timeline/item-clamps so
  the runtime items-store normalizer and the project-load migration share
  one source of truth; collapses ~150 lines of repeated if-clamp branches
  in migrations/normalize.ts.
- Move replaceableCaptionClipIds out of withItemIndexes' hot path: it now
  rebuilds lazily via selectReplaceableCaptionClipIds, keyed on items
  identity, so per-frame drag mutations skip the O(N) legacy-caption
  detection pass.
- Drop the redundant top-level xxxRef.current = xxx writes in
  use-smart-trim-hover; the syncXxx helpers were already updating the
  refs, the duplicate writes only existed to catch the tool-change
  useEffect's direct setX calls which now route through syncXxx.
- Replace `mediaItems.find(...)` lookups with O(1) `mediaById[id]` across
  runtime, timeline, preview, and media-library call sites (incl. two
  playback hot paths in composition-runtime).
- preview-area: use precomputed `useItemsStore.maxItemEndFrame` instead
  of looping all items inside a Zustand selector on every store update.
- timeline-track: replace per-row `resolveEffectiveTrackStates(s.tracks)`
  call with a direct O(1) parent-group lookup so visible tracks don't
  do O(T^2) work on every store mutation.
- inline-composition-preview: drop the redundant `compositionById`
  subscription and read it lazily inside the resolution effect so
  unrelated composition edits no longer re-trigger media URL resolution.
- media-resolver: swap `JSON.parse(JSON.stringify(tracks))` for
  `structuredClone(tracks)`.
- use-preview-view-model: count proxy "ready" statuses in `useMemo`
  keyed on `proxyStatus` instead of looping inside the selector on
  every media-library mutation.

Test mocks updated to expose `mediaById` alongside `mediaItems`.
… jank

ClipContent drove its filmstrip/waveform tile grid from the live per-frame
pixelsPerSecond, so it re-rendered on every wheel/momentum frame during a zoom
gesture. Profiling a prod build (21 visible clips) showed this was ~73% of zoom
cost — ~2,388 ClipContent renders per gesture, ~104ms/frame (~10fps).

Switch ClipContent to contentPixelsPerSecond (settled, updates ~100ms after the
gesture ends). The clip shell still resizes smoothly mid-gesture via the
--timeline-px-per-frame CSS variable, and the existing cover-frame background /
overflow clipping hide the transient scale mismatch until it snaps sharp on
settle. preferImmediateRendering opts back into live pps for active edit
previews (trim/slide) where settle lag would distract — matching the intent the
existing tests already documented.

Result: ~2,388 -> 60 ClipContent renders per gesture, ~5,200ms -> ~1,400ms for
a 50-frame zoom (~10fps -> ~33fps).

Also adds User Timing instrumentation used to find this: withPerfMeasure wraps
the timeline RAF loops and mutation execute()/applyTransitionRepairs as tl.raf.*
/ tl.action.* entries; perfMarkRender adds opt-in tl.render.* marks (gated on
window.__TL_RENDER_MARKS__); __DEBUG__.perfSummary()/perfClear() aggregate them.
…m-out lag

Zoom-out was ~5x slower than zoom-in (226ms vs 40ms per frame with 21 clips).
Render marks ruled out React render count; the cost is that zooming out grows
the visible-clip count, and mounting each newly-visible clip's filmstrip tile
grid + waveform (canvas draws) dominates. Disabling filmstrips dropped zoom-out
to 19ms/frame, confirming the source.

ClipContent now reads isZoomInteracting once at mount (via getState, not a
reactive subscription) into deferVisual. A clip that first mounts mid-gesture
renders only its colored shell — no filmstrip/waveform — until the zoom settles,
then a one-shot subscription flips it on. Reading at mount rather than
subscribing is essential: already-mounted clips must not re-render when the flag
flips, or they would flash empty.

Zoom-out: ~226ms/frame -> ~42ms/frame (clean runs 27-37ms). Clips appear as
colored blocks during a fast zoom-out and fill with thumbnails on settle.
…de path

audioBufferToWavBlob used a per-sample DataView.setInt16 loop (1129ms self
on the main thread in a profile — the single biggest app function), and
int16ToFloat32 divided per sample. Both run on the main thread during audio
decode and froze the UI for ~2s.

Write PCM through an Int16Array view over the data region (byte-identical
output; browsers are little-endian, which WAV requires) and convert Int16->
Float32 via precomputed reciprocals.
The full preview-audio decode (mediabunny decode + downmix + downsample +
Int16) ran on the main thread, freezing the UI for ~1-2s on import/first
load. Move it into a worker that streams transferable Int16 bins back; the
main thread only persists bins and wraps the assembled result in an
AudioBuffer (cheap memcpy) — AudioBuffer can't cross the worker boundary.

- audio-decode-dsp.ts: pure, AudioBuffer-free DSP shared by both paths so
  worker and main-thread output stay byte-identical
- audio-decode-worker.ts: mirrors the existing waveform-worker pattern
  (mediabunny, AC-3, createMediabunnyInputSource)
- audio-decode-cache.ts: prefer the worker, fall back to the existing
  main-thread decode when Worker is unavailable (tests) or the worker errors

The latency-sensitive partial/streaming playback path is unchanged (Phase 2).
Playback correctness still needs manual verification in the running app.
The fast-start playback window decode (decodeAudioWindow) ran on the main
thread, competing with audio scheduling and video rendering at the exact
moment playback begins. Move it into the worker via a new decode-window
message that returns a Float32 stereo slice (not persisted, so no Int16
quantization); the main thread only wraps it in an AudioBuffer.

- Worker uses AudioSampleSink + getSample/samples(start,end) for the window
  (AudioBuffer/AudioBufferSink are unavailable in workers), mirroring the
  main-thread seek/dedup/coverage logic
- Dedicated foreground worker lane so a window decode is never queued behind
  a slow background full decode on the same thread
- decodeAudioWindowPreferWorker falls back to the existing main-thread
  decodeAudioWindow when Worker is unavailable (tests) or the worker errors;
  the outer timeout-race + full-decode fallback is unchanged

Playback correctness still needs manual verification in the running app.
After Phase 1 the worker decoded off-thread but the main thread still wrote
every bin to the workspace (the bulk of the ~950ms of storage I/O in the
profile: getFileHandle, createWritable, write per bin x3 files). Hand the
workspace FileSystemDirectoryHandle to the worker (it's structured-cloneable
and keeps its permission grant) so the worker writes bins itself.

- decoded-preview-audio.ts: extract writeDecodedPreviewAudioToRoot(root, data)
  so the worker reuses the exact path/format logic; saveDecodedPreviewAudio
  delegates with requireWorkspaceRoot()
- worker writes each bin before transferring it back (transfer neuters the
  buffer, so the write must read it first), then streams it for AudioBuffer
  assembly
- main thread skips per-bin saves when it handed over the handle; the tiny
  meta JSON and the conform WAV stay on the main thread
- falls back to main-thread persistence when no workspace root is available
  (e.g. tests) or the worker errors

Playback + on-disk cache correctness still needs manual verification in app.
When an AC-3 clip scrolled back into view, startPreviewAudioConform always
ran ensureDecodeStarted (a ~800ms loadFromBins Int16->Float32 rebuild on the
main thread) and re-encoded the WAV, even when the conform asset was already
persisted. LoAF profiling showed these as ~800ms blocking Promise.resolve
storms during scroll.

- Bail out of startPreviewAudioConform before the decode when the asset is
  already conformed (isPreviewAudioConformed guard).
- Skip already-conformed assets in persistConform; fixes a latent double WAV
  encode in the old path.
- Encode the conform WAV directly from the persisted Int16 bins
  (int16StereoToWavBlob) instead of round-tripping Int16->Float32->Int16.
- int16ToFloat32Into writes straight into the channel buffer, dropping a
  throwaway Float32Array allocation and an extra copy per bin at all three
  AudioBuffer assembly sites.

Verified live: re-scroll blocking dropped from ~1616ms to ~150ms.
loadFromDisk only short-circuited on isComplete, so an incomplete (long-video)
filmstrip re-read every persisted tile from OPFS and re-minted ~230 object URLs
on every scroll remount. Live profiling showed ~230 getFile calls per scroll
pass purely from this path.

Skip loadFromDisk when the singleton in-memory cache already holds frames for
the media. The live extraction path keeps that cache current via notifyUpdate,
and a fully-evicted entry (which is the only path that revokes the object URLs,
and only runs with no subscribers) resets the in-memory frames to empty so a
genuine reload still runs when needed.

Verified live: filmstrip getFile reads during a re-scroll dropped from ~230 to
0, with no broken tiles.
loadFromBins (the cache-hit path) dequantized persisted Int16 bins into an
AudioBuffer synchronously on the main thread — a ~900ms freeze that recurs
whenever a clip scrolls back into view. Move the Int16->Float32 assembly to
the existing decode worker via a new assemble-bins message (reusing the shared
int16ToFloat32Into DSP for byte-identical output), then copyToChannel the
result. Bin buffers are copied rather than transferred so a worker failure
falls back to the original synchronous loop.
Two layout-thrash sources surfaced in a DevTools trace, both firing during
React commits on scroll/zoom frames:

- use-fade-editors read getBoundingClientRect per audio clip inside a
  useLayoutEffect to pixel-snap the volume line. Many clips mounting at once
  (zoom-out) meant one forced reflow each, on the commit critical path. The
  line now carries a `top: var(--timeline-audio-volume-line-y)` CSS fallback so
  it paints in the right place without JS, and the snap moves to a post-paint
  useEffect (refinement only, no flash).

- syncViewportFromContainer read clientWidth/clientHeight every scroll/zoom
  frame even though both are invariant under scroll and horizontal zoom. Cache
  them, refreshed by a ResizeObserver on the container + tracks wrapper, with a
  live-read fallback until the first measurement so culling can't regress.
walterlow added 6 commits May 28, 2026 15:11
…xing

mixCompoundClipWaveformPeaks runs Math.hypot per output sample. Peaks are
normalized to [0,1] so there is no overflow to guard against, and
Math.sqrt(a*a + b*b) is several times faster than V8's variadic Math.hypot.
…t-visibility

The zoom re-anchor scrollLeft write forces a synchronous reflow whose cost
scales with the interior layout (label, filmstrip, waveform SVG, fade overlays)
of every mounted clip — including the ~2/3 that sit in the 2000px cull buffer
but off-screen. Adding `content-visibility: auto` to the clip shell lets the
browser skip laying out those off-screen interiors; the box stays correctly
sized from inset-y + explicit left/width, so nothing collapses.

Measured on a zoom-heavy trace: `set scrollLeft` self-time dropped from ~910ms
to ~278ms (~68% less per zoom frame) with no regression elsewhere.
…r race

- Gate withPerfMeasure behind window.__TL_PERF__ (off by default) so the
  User Timing buffer no longer grows unbounded in prod — measure entries
  were never cleared and ran on every timeline action + RAF frame. Matches
  the existing perfMarkRender gating.
- ClipContent deferVisual now re-checks isZoomInteracting on effect attach,
  so a clip whose zoom gesture settles between the mount-time read and the
  subscription no longer stays stuck shell-only.
Bundle container/codec/quality/resolution into Maximum/Recommended/Balanced/Small
presets so users don't need codec knowledge to pick good defaults. Presets keep the
project aspect ratio (scale only) to avoid distorted output; manually changing any
control flips the indicator to Custom. Localized across all 9 languages.
…e partials

Split the 6.5k-line missing.json catch-all into export/media/preview partials and
fold its editor/effects/timeline slices into the existing per-feature files. Since
missing.json was the lowest-priority partial, only its keys that no other partial
defined were carried over (shadowed keys were dead). The merged translation tree is
byte-identical across all 9 languages. Also simplified the loader's partial ordering
to plain alphabetical now that the missing-first fallback is gone.
…embed gating

- Captions: add a default caption style preset setting (picker in Settings → AI);
  transcript and AI caption generation now apply it on creation. Move
  caption-style-presets to shared/typography so settings/media-library can use it
  within feature boundaries.
- Media library: aggregate proxy/transcription progress bars expand to per-item
  rows (name + %); analysis cancel reads "Cancel all" when multiple items run.
- Export: subtitle-embed toggle now disables with a note on containers that can't
  carry subtitles (MOV) instead of blocking the export.
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 28, 2026

Too many files changed for review. (122 files found, 100 file limit)

@vercel
Copy link
Copy Markdown

vercel Bot commented May 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
freecut Ready Ready Preview, Comment May 28, 2026 9:53am

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1a8c7f65-ab57-414e-bd27-90089726e20c

📥 Commits

Reviewing files that changed from the base of the PR and between 5166a2a and cee8f9c.

📒 Files selected for processing (5)
  • src/features/timeline/components/timeline-item/transcribe-dialog-controller.tsx
  • src/features/timeline/components/timeline-item/use-context-menu-state.ts
  • src/i18n/locales/partials/preview.json
  • src/runtime/composition-runtime/utils/audio-decode-dsp.test.ts
  • src/shared/logging/perf-marks.ts
✅ Files skipped from review due to trivial changes (1)
  • src/i18n/locales/partials/preview.json
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/features/timeline/components/timeline-item/use-context-menu-state.ts
  • src/runtime/composition-runtime/utils/audio-decode-dsp.test.ts
  • src/shared/logging/perf-marks.ts
  • src/features/timeline/components/timeline-item/transcribe-dialog-controller.tsx

📝 Walkthrough

Walkthrough

Adds shared project defaults, perf-mark utilities and debug API, migrates media lookups to mediaById, introduces audio decode worker and DSP utilities, refactors timeline hooks/components and filmstrip behavior, adds caption-style presets and export presets, reuses fullscreen WGSL, and updates tests and locales.

Changes

Unified Defaults, Perf, Media Map, and Audio Worker

Layer / File(s) Summary
End-to-end implementation and wiring
src/shared/projects/defaults.ts, src/shared/logging/perf-marks.ts, src/features/timeline/**/*, src/runtime/composition-runtime/utils/audio-decode-*.ts*, src/features/settings/*, src/features/export/*, src/features/media-library/*, src/i18n/**/*, src/infrastructure/gpu-*/**/*, .gitignore, CLAUDE.md, tests
Replaces hardcoded 1920×1080/30 with shared defaults, adds perf marks and debug perf summary/clear, switches many media lookups to mediaById, introduces audio decode worker/DSP with bin persistence/assembly, adds caption style preset setting/usage, export presets and subtitle embedding checks, timeline hooks/components refactors, GPU WGSL vertex reuse, and localization updates.

Sequence Diagram

sequenceDiagram
  participant UI as Main Thread UI
  participant Store as Stores
  participant Worker as AudioDecodeWorker
  participant FS as Workspace FS
  UI->>Store: request decode/conform(mediaId)
  UI->>Worker: decode (source + metadata)
  Worker-->>UI: bin responses (Int16 buffers) & complete
  Worker->>FS: persist bins
  UI->>Worker: assemble-bins
  Worker-->>UI: assembled Float32 buffers
  UI->>FS: write WAV (int16 or AudioBuffer)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

"I’m a rabbit in the repo burrow, tapping marks that time the frame,
Defaults snug like carrot beds, no magic numbers to reclaim.
Workers hum beneath the hill, converting bins to waves so neat,
Media maps fetch faster now — no list to scan instead.
Perf marks blink, presets sing — hop, commit, and maybe treat! 🐇"

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch develop

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (6)
src/features/preview/hooks/use-preview-view-model.ts (1)

80-87: ⚡ Quick win

Keep proxyReadyCount derived inside the selector.

Line 80 subscribes to proxyStatus by reference; if that map is ever mutated in place, Line 81–87 can keep a stale count. Deriving the count in the selector avoids that edge case and preserves update robustness.

Suggested change
-  const proxyStatus = useMediaLibraryStore((s) => s.proxyStatus)
-  const proxyReadyCount = useMemo(() => {
-    let count = 0
-    for (const status of proxyStatus.values()) {
-      if (status === 'ready') count++
-    }
-    return count
-  }, [proxyStatus])
+  const proxyReadyCount = useMediaLibraryStore((s) => {
+    let count = 0
+    for (const status of s.proxyStatus.values()) {
+      if (status === 'ready') count++
+    }
+    return count
+  })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/preview/hooks/use-preview-view-model.ts` around lines 80 - 87,
proxyReadyCount is computed from proxyStatus after subscribing to proxyStatus by
reference, which can miss updates if the Map is mutated in place; move the
derivation into the selector passed to useMediaLibraryStore so the hook
subscribes to the derived value instead of the Map reference. Change the current
useMemo + proxyStatus usage to a selector like useMediaLibraryStore(s => /*
compute ready count from s.proxyStatus */) so the selector returns the count
(proxyReadyCount) directly and the component only subscribes to that derived
number.
src/infrastructure/gpu-effects/index.test.ts (1)

161-168: ⚡ Quick win

Strengthen the empty-category assertion.

This test currently validates only that the return type is an array, so it can pass even when the category is not actually empty. Assert the returned list is truly empty for a guaranteed-missing key.

♻️ Suggested test tightening
-  it('returns empty list for an empty category', () => {
+  it('returns empty list for an unknown category', () => {
     // Categories with no registered effects (none today) should return [],
     // not throw. Pass a name that is in the union but lacks effects — if
     // none exists, any unknown key returns [] via nullish coalescing.
     // We assert the well-typed surface area here.
-    const result = getGpuEffectsByCategory('color')
-    expect(Array.isArray(result)).toBe(true)
+    const result = getGpuEffectsByCategory(
+      '__unknown_category__' as Parameters<typeof getGpuEffectsByCategory>[0],
+    )
+    expect(result).toEqual([])
   })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/infrastructure/gpu-effects/index.test.ts` around lines 161 - 168, Update
the test that calls getGpuEffectsByCategory('color') to assert the array is
actually empty rather than only checking its type: replace or augment the
Array.isArray check with a strict emptiness assertion such as
expect(result).toHaveLength(0) or expect(result).toEqual([]) so the test fails
if any effects are returned from getGpuEffectsByCategory('color').
src/runtime/composition-runtime/utils/audio-decode-dsp.test.ts (1)

89-105: 💤 Low value

Test assertion may not match actual conversion behavior.

Line 104 uses Math.trunc(0.5 * 0x7fff) but float32ToInt16 doesn't explicitly truncate—it assigns directly to Int16Array which performs ToInt16. For positive values this is effectively truncation, but the test comment could be clearer about why this works.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/composition-runtime/utils/audio-decode-dsp.test.ts` around lines
89 - 105, The test's expected value uses Math.trunc(0.5 * 0x7fff) but the
implementation converts floats by assigning into an Int16Array (which performs
JS ToInt16 semantics); update the assertion to compute the expected value using
the same conversion pipeline as the code under test—for example, replace
Math.trunc(...) with a conversion that mirrors assignment into an Int16Array
(e.g., new Int16Array([0.5 * 0x7fff])[0]) so the test for produceDecodedBin's
left channel uses the identical conversion as float-to-int16 conversion in the
runtime.
src/runtime/composition-runtime/utils/audio-decode-cache.ts (1)

674-753: ⚖️ Poor tradeoff

Event listener cleanup is tied to message receipt but not to promise rejection paths.

If finalizeDecodedAudio throws after cleanup() is called (line 717), the rejection propagates correctly. However, if the worker never responds (e.g., worker crashes silently without firing error event), the promise will hang indefinitely and listeners remain attached.

Consider adding a timeout or using AbortController for long-running decode operations, or at minimum document this as a known limitation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/composition-runtime/utils/audio-decode-cache.ts` around lines 674
- 753, The promise in decodeFullAudioViaWorker can hang and leave listeners
attached if the worker never responds; add a cancellation/timeout path that
calls cleanup(), rejects the promise, and stops further processing. Modify
decodeFullAudioViaWorker to accept an optional AbortSignal or create an internal
timeout timer that (a) removes the onMessage/onError listeners via cleanup(),
(b) rejects with a descriptive Error (e.g., "Audio decode timed out" or
"aborted"), and (c) aborts any outstanding persistPromises; ensure onMessage and
onError clear the timer/abort handler when they complete and keep the existing
finalizeDecodedAudio try/catch behavior. Use the function names requestId,
onMessage, onError, cleanup, finalizeDecodedAudio and variables
persistPromises/persistedBins to locate where to wire the
timeout/AbortController logic.
src/runtime/composition-runtime/utils/audio-decode-dsp.ts (1)

109-113: 💤 Low value

Dead code: LFE branch can never execute.

lfeGain is hardcoded to 0 at line 84, so the condition lfeGain !== 0 && LFE is always false. The block at lines 110-113 is unreachable. Consider removing this dead code or adding a comment explaining it's preserved for potential future configurability.

♻️ Suggested cleanup
-    if (lfeGain !== 0 && LFE) {
-      const lfe = LFE[i]! * lfeGain
-      l += lfe
-      r += lfe
-    }
+    // LFE channel (index 3) intentionally discarded for preview — adds mud
+    // without improving intelligibility at preview sample rates.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/composition-runtime/utils/audio-decode-dsp.ts` around lines 109 -
113, The branch guarded by lfeGain and LFE in audio-decode-dsp.ts is dead
because lfeGain is initialized to 0 (making "if (lfeGain !== 0 && LFE)" always
false); either remove the unreachable block that adds LFE to l and r (the `if
(lfeGain !== 0 && LFE) { const lfe = LFE[i]! * lfeGain; l += lfe; r += lfe; }`
section) or make lfeGain configurable (expose it as a parameter/option and use
that value here) — if you choose to keep the code for future use, add a clear
comment/TODO next to the lfeGain/LFE usage explaining why it's preserved and
under what configuration it will be enabled.
src/shared/logging/perf-marks.ts (1)

63-80: ⚡ Quick win

Fix nested withPerfMeasure timing collisions by uniquifying mark names

withPerfMeasure uses ${name}:s / ${name}:e and then calls performance.clearMarks(startMark) / clearMarks(endMark). Since clearMarks(markName) removes all PerformanceMark entries matching that name, nested/re-entrant calls using the same name can clear the outer call’s marks before the outer performance.measure(...) runs, dropping/mangling the outer measurement.

♻️ Proposed fix
+let perfMeasureSeq = 0
+
 export function withPerfMeasure<T>(name: string, fn: () => T): T {
   if (!HAS_PERF || !(globalThis as { __TL_PERF__?: boolean }).__TL_PERF__) return fn()
-  const startMark = `${name}:s`
-  const endMark = `${name}:e`
+  const runId = `${name}:${++perfMeasureSeq}`
+  const startMark = `${runId}:s`
+  const endMark = `${runId}:e`
   try {
     performance.mark(startMark)
   } catch {
     return fn()
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/shared/logging/perf-marks.ts` around lines 63 - 80, withPerfMeasure
currently uses non-unique mark names (`${name}:s` / `${name}:e`) which lets
nested calls clobber outer marks; change it to generate unique start/end mark
names per invocation (e.g., append a small unique suffix from a module-scoped
counter or crypto.randomUUID()) so each call uses `const startMark =
`${name}:s:${unique}`` and `const endMark = `${name}:e:${unique}``; use those
unique names in performance.mark, performance.measure, and
performance.clearMarks so inner calls cannot remove outer marks; keep the
measurement name (the first arg to performance.measure) as the original `name`
if desired.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/features/timeline/components/timeline-item/transcribe-dialog-controller.tsx`:
- Around line 73-83: The onStart handler calls markCaptionStarted() then invokes
onGenerate(...) but doesn't guard against synchronous throws, leaving caption
state open and no error shown; wrap the onGenerate(...) call in a try/catch so
any synchronous exception triggers markCaptionEnded() and sets
setDialogError(...) (using isTranscriptionOutOfMemoryError and
TRANSCRIPTION_OOM_HINT the same way as the async callback), while preserving the
existing onGenerate callback that also calls markCaptionEnded() and
setDialogError(...) for async failures.

In `@src/features/timeline/components/timeline-item/use-context-menu-state.ts`:
- Around line 27-43: The current isCurrentSelection uses targetIds.some(...) so
any partial overlap prevents selection updates; change the membership check to
require full containment by using targetIds.every(id =>
selectedItemIds.includes(id)) (i.e., update the isCurrentSelection definition).
Keep the rest of the logic (selectItems, expandSelectionWithLinkedItems, items,
selectedItemIds, item.id) unchanged so only fully-contained target sets are
treated as current selection.

In `@src/features/timeline/components/timeline-track.tsx`:
- Around line 294-306: Replace the inline parent-group logic in the
useTimelineStore selector for trackInteractionState with a call to the shared
resolver: import and call resolveEffectiveTrackStates (from group-utils.ts)
passing the current track and the store's tracks collection to obtain the
canonical effective track object, then compute the same bitmask using
(effective.locked ? 1 : 0) | (getIsTrackDisabled(effective) ? 2 : 0); keep this
inside the same useTimelineStore selector (trackInteractionState) and retain
getIsTrackDisabled usage so group gating behavior always flows through
resolveEffectiveTrackStates.

In `@src/i18n/locales/partials/preview.json`:
- Line 279: The German translation for the key "frameSaved" mixes a German
opening quote („) with an escaped ASCII closing quote (\")—update the value for
"frameSaved" to use a consistent quote style (either both German typographic
quotes „…“ or both ASCII quotes "…") so it matches the rest of the locale
strings; modify the string in src/i18n/locales/partials/preview.json at the
"frameSaved" key accordingly.

---

Nitpick comments:
In `@src/features/preview/hooks/use-preview-view-model.ts`:
- Around line 80-87: proxyReadyCount is computed from proxyStatus after
subscribing to proxyStatus by reference, which can miss updates if the Map is
mutated in place; move the derivation into the selector passed to
useMediaLibraryStore so the hook subscribes to the derived value instead of the
Map reference. Change the current useMemo + proxyStatus usage to a selector like
useMediaLibraryStore(s => /* compute ready count from s.proxyStatus */) so the
selector returns the count (proxyReadyCount) directly and the component only
subscribes to that derived number.

In `@src/infrastructure/gpu-effects/index.test.ts`:
- Around line 161-168: Update the test that calls
getGpuEffectsByCategory('color') to assert the array is actually empty rather
than only checking its type: replace or augment the Array.isArray check with a
strict emptiness assertion such as expect(result).toHaveLength(0) or
expect(result).toEqual([]) so the test fails if any effects are returned from
getGpuEffectsByCategory('color').

In `@src/runtime/composition-runtime/utils/audio-decode-cache.ts`:
- Around line 674-753: The promise in decodeFullAudioViaWorker can hang and
leave listeners attached if the worker never responds; add a
cancellation/timeout path that calls cleanup(), rejects the promise, and stops
further processing. Modify decodeFullAudioViaWorker to accept an optional
AbortSignal or create an internal timeout timer that (a) removes the
onMessage/onError listeners via cleanup(), (b) rejects with a descriptive Error
(e.g., "Audio decode timed out" or "aborted"), and (c) aborts any outstanding
persistPromises; ensure onMessage and onError clear the timer/abort handler when
they complete and keep the existing finalizeDecodedAudio try/catch behavior. Use
the function names requestId, onMessage, onError, cleanup, finalizeDecodedAudio
and variables persistPromises/persistedBins to locate where to wire the
timeout/AbortController logic.

In `@src/runtime/composition-runtime/utils/audio-decode-dsp.test.ts`:
- Around line 89-105: The test's expected value uses Math.trunc(0.5 * 0x7fff)
but the implementation converts floats by assigning into an Int16Array (which
performs JS ToInt16 semantics); update the assertion to compute the expected
value using the same conversion pipeline as the code under test—for example,
replace Math.trunc(...) with a conversion that mirrors assignment into an
Int16Array (e.g., new Int16Array([0.5 * 0x7fff])[0]) so the test for
produceDecodedBin's left channel uses the identical conversion as float-to-int16
conversion in the runtime.

In `@src/runtime/composition-runtime/utils/audio-decode-dsp.ts`:
- Around line 109-113: The branch guarded by lfeGain and LFE in
audio-decode-dsp.ts is dead because lfeGain is initialized to 0 (making "if
(lfeGain !== 0 && LFE)" always false); either remove the unreachable block that
adds LFE to l and r (the `if (lfeGain !== 0 && LFE) { const lfe = LFE[i]! *
lfeGain; l += lfe; r += lfe; }` section) or make lfeGain configurable (expose it
as a parameter/option and use that value here) — if you choose to keep the code
for future use, add a clear comment/TODO next to the lfeGain/LFE usage
explaining why it's preserved and under what configuration it will be enabled.

In `@src/shared/logging/perf-marks.ts`:
- Around line 63-80: withPerfMeasure currently uses non-unique mark names
(`${name}:s` / `${name}:e`) which lets nested calls clobber outer marks; change
it to generate unique start/end mark names per invocation (e.g., append a small
unique suffix from a module-scoped counter or crypto.randomUUID()) so each call
uses `const startMark = `${name}:s:${unique}`` and `const endMark =
`${name}:e:${unique}``; use those unique names in performance.mark,
performance.measure, and performance.clearMarks so inner calls cannot remove
outer marks; keep the measurement name (the first arg to performance.measure) as
the original `name` if desired.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9bf91648-acb4-4ec5-9cfd-5c2c5152745d

📥 Commits

Reviewing files that changed from the base of the PR and between eaf4160 and 5166a2a.

📒 Files selected for processing (122)
  • .gitignore
  • CLAUDE.md
  • src/app/debug/project-debug.ts
  • src/features/editor/components/media-sidebar.tsx
  • src/features/editor/components/preview-area.tsx
  • src/features/editor/components/properties-sidebar/canvas-panel/index.tsx
  • src/features/editor/components/properties-sidebar/clip-panel/caption-style-controls.tsx
  • src/features/editor/components/properties-sidebar/clip-panel/index.tsx
  • src/features/editor/components/properties-sidebar/clip-panel/layout-section.tsx
  • src/features/editor/components/properties-sidebar/clip-panel/subtitle-section.tsx
  • src/features/editor/components/properties-sidebar/clip-panel/video-section.tsx
  • src/features/editor/components/settings-dialog.tsx
  • src/features/export/components/export-dialog.tsx
  • src/features/export/hooks/use-client-render.ts
  • src/features/export/utils/canvas-render-orchestrator.ts
  • src/features/export/utils/client-renderer.ts
  • src/features/export/utils/shared-video-extractor.ts
  • src/features/keyframes/components/dopesheet-editor/index.tsx
  • src/features/keyframes/components/dopesheet-editor/use-dopesheet-viewport.ts
  • src/features/keyframes/components/dopesheet-editor/use-element-size.ts
  • src/features/media-library/components/background-task-progress.tsx
  • src/features/media-library/components/media-card.tsx
  • src/features/media-library/components/media-library.tsx
  • src/features/media-library/services/media-analysis-service.ts
  • src/features/media-library/services/media-captioning-service.ts
  • src/features/media-library/services/media-transcription-service.ts
  • src/features/media-library/services/subtitle-sidecar-service.ts
  • src/features/media-library/utils/caption-items.ts
  • src/features/media-library/utils/media-resolver.ts
  • src/features/media-library/workers/media-processor.worker.ts
  • src/features/preview/components/inline-composition-preview.tsx
  • src/features/preview/components/source-monitor.test.tsx
  • src/features/preview/components/source-monitor.tsx
  • src/features/preview/hooks/use-canvas-media-drop.ts
  • src/features/preview/hooks/use-preview-view-model.ts
  • src/features/projects/components/project-card.tsx
  • src/features/projects/utils/validation.ts
  • src/features/settings/stores/settings-store.ts
  • src/features/timeline/components/bento-layout-dialog.tsx
  • src/features/timeline/components/keyframe-graph-panel.tsx
  • src/features/timeline/components/timeline-content.tsx
  • src/features/timeline/components/timeline-item/clip-content.test.tsx
  • src/features/timeline/components/timeline-item/clip-content.tsx
  • src/features/timeline/components/timeline-item/edge-halos.tsx
  • src/features/timeline/components/timeline-item/index.tsx
  • src/features/timeline/components/timeline-item/transcribe-dialog-controller.tsx
  • src/features/timeline/components/timeline-item/transition-drop-ghost.tsx
  • src/features/timeline/components/timeline-item/use-caption-dialog-state.ts
  • src/features/timeline/components/timeline-item/use-context-menu-state.ts
  • src/features/timeline/components/timeline-item/use-edit-preview-shifts.ts
  • src/features/timeline/components/timeline-item/use-fade-editors.ts
  • src/features/timeline/components/timeline-item/use-fade-math.ts
  • src/features/timeline/components/timeline-item/use-smart-trim-hover.ts
  • src/features/timeline/components/timeline-item/use-timeline-item-actions.ts
  • src/features/timeline/components/timeline-item/use-timeline-item-bounds.ts
  • src/features/timeline/components/timeline-markers.tsx
  • src/features/timeline/components/timeline-media-drop-zone.tsx
  • src/features/timeline/components/timeline-playhead.tsx
  • src/features/timeline/components/timeline-track.tsx
  • src/features/timeline/components/transition-item.tsx
  • src/features/timeline/hooks/use-filmstrip.ts
  • src/features/timeline/services/filmstrip-cache-config.ts
  • src/features/timeline/services/filmstrip-cache-metrics.ts
  • src/features/timeline/services/filmstrip-cache.ts
  • src/features/timeline/services/reverse-conform-service.ts
  • src/features/timeline/stores/actions/composition-actions.ts
  • src/features/timeline/stores/actions/edit/freeze-frame-actions.ts
  • src/features/timeline/stores/actions/shared.ts
  • src/features/timeline/stores/actions/source-edit-actions.ts
  • src/features/timeline/stores/items-store-indexes.ts
  • src/features/timeline/stores/items-store-normalize.test.ts
  • src/features/timeline/stores/items-store-normalize.ts
  • src/features/timeline/stores/items-store.test.ts
  • src/features/timeline/stores/items-store.ts
  • src/features/timeline/stores/timeline-persistence.ts
  • src/features/timeline/utils/compound-clip-waveform.ts
  • src/i18n/index.ts
  • src/i18n/locales/de.json
  • src/i18n/locales/en.json
  • src/i18n/locales/es.json
  • src/i18n/locales/fr.json
  • src/i18n/locales/ja.json
  • src/i18n/locales/ko.json
  • src/i18n/locales/partials/editor.json
  • src/i18n/locales/partials/effects.json
  • src/i18n/locales/partials/export.json
  • src/i18n/locales/partials/media.json
  • src/i18n/locales/partials/missing.json
  • src/i18n/locales/partials/preview.json
  • src/i18n/locales/partials/timeline.json
  • src/i18n/locales/pt-BR.json
  • src/i18n/locales/tr.json
  • src/i18n/locales/zh.json
  • src/infrastructure/gpu-compositor/compositor-pipeline.ts
  • src/infrastructure/gpu-effects/common.ts
  • src/infrastructure/gpu-effects/effects-pipeline.ts
  • src/infrastructure/gpu-effects/index.test.ts
  • src/infrastructure/gpu-masks/mask-combine-pipeline.ts
  • src/infrastructure/gpu-media/media-blend-pipeline.ts
  • src/infrastructure/gpu-media/media-render-pipeline.ts
  • src/infrastructure/gpu-shapes/shape-render-pipeline.ts
  • src/infrastructure/gpu-shared/fullscreen-quad.ts
  • src/infrastructure/gpu-transitions/common.ts
  • src/infrastructure/gpu-transitions/index.test.ts
  • src/infrastructure/storage/workspace-fs/decoded-preview-audio.ts
  • src/runtime/composition-runtime/components/item.tsx
  • src/runtime/composition-runtime/components/stable-video-sequence.test.tsx
  • src/runtime/composition-runtime/components/stable-video-sequence.tsx
  • src/runtime/composition-runtime/components/text-content.tsx
  • src/runtime/composition-runtime/utils/audio-buffer-wav.ts
  • src/runtime/composition-runtime/utils/audio-decode-cache.test.ts
  • src/runtime/composition-runtime/utils/audio-decode-cache.ts
  • src/runtime/composition-runtime/utils/audio-decode-dsp.test.ts
  • src/runtime/composition-runtime/utils/audio-decode-dsp.ts
  • src/runtime/composition-runtime/utils/audio-decode-worker.ts
  • src/runtime/composition-runtime/utils/audio-decode-worker.types.ts
  • src/runtime/composition-runtime/utils/preview-audio-conform.ts
  • src/shared/logging/perf-marks.ts
  • src/shared/projects/defaults.ts
  • src/shared/projects/migrations/normalize.ts
  • src/shared/timeline/item-clamps.ts
  • src/shared/typography/caption-style-presets.ts

Comment thread src/features/timeline/components/timeline-item/use-context-menu-state.ts Outdated
Comment on lines +294 to 306
// Direct parent-group lookup keeps this O(1); calling resolveEffectiveTrackStates
// here would scan the whole tracks array per row on every store mutation.
const trackInteractionState = useTimelineStore((s) => {
const effective = resolveEffectiveTrackStates(s.tracks).find((t) => t.id === track.id) ?? track
const parentGroup = track.parentTrackId
? s.tracks.find((t) => t.id === track.parentTrackId && t.isGroup)
: undefined
const effectiveLocked = track.locked || parentGroup?.locked || false
const effectiveMuted = track.muted || parentGroup?.muted || false
const effectiveVisible = track.visible !== false && parentGroup?.visible !== false
const effective: TimelineTrackType = parentGroup
? { ...track, locked: effectiveLocked, muted: effectiveMuted, visible: effectiveVisible }
: track
return (effective.locked ? 1 : 0) | (getIsTrackDisabled(effective) ? 2 : 0)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use resolveEffectiveTrackStates() for group gating instead of inline logic.

This block bypasses the shared propagation path and can drift from canonical mute/visible/locked behavior for child tracks. Please route effective state resolution through group-utils.ts here.

As per coding guidelines, "Track groups are 1-level only (no nested groups). Gate behavior (mute/visible/locked) propagates from group to children via resolveEffectiveTrackStates() in group-utils.ts."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/timeline/components/timeline-track.tsx` around lines 294 - 306,
Replace the inline parent-group logic in the useTimelineStore selector for
trackInteractionState with a call to the shared resolver: import and call
resolveEffectiveTrackStates (from group-utils.ts) passing the current track and
the store's tracks collection to obtain the canonical effective track object,
then compute the same bitmask using (effective.locked ? 1 : 0) |
(getIsTrackDisabled(effective) ? 2 : 0); keep this inside the same
useTimelineStore selector (trackInteractionState) and retain getIsTrackDisabled
usage so group gating behavior always flows through resolveEffectiveTrackStates.

Comment thread src/i18n/locales/partials/preview.json Outdated
- transcribe dialog: catch synchronous onGenerate throws so caption state
  resets and an OOM-aware error surfaces like the async path
- context menu: require full containment (every) so a partially-selected
  linked set expands to the full set on right-click
- i18n: use matching German typographic quotes in preview frameSaved
- audio-decode test: mirror Int16Array conversion instead of Math.trunc
- perf-marks: unique start/end mark names so nested calls don't clobber
@walterlow walterlow merged commit 13d3d8d into staging May 28, 2026
5 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.

1 participant