Skip to content

feat(editor): imported audio as a first-class timeline region - #569

Open
EtienneLescot wants to merge 79 commits into
mainfrom
feat/imported-audio
Open

feat(editor): imported audio as a first-class timeline region#569
EtienneLescot wants to merge 79 commits into
mainfrom
feat/imported-audio

Conversation

@EtienneLescot

@EtienneLescot EtienneLescot commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Imported audio, whole — built on @olamide226's branch rather than beside it.

This PR was originally an independent convergence of #502 and #526. It has been rebuilt: #561's branch is the base, and the four things it did not have were ported onto it. The reason is credit, and it is not a gesture. Ola did the pivot he was asked for on 2026-08-29 and opened #561 on 09-01, while a second implementation of the same feature was landing in parallel. Rebasing onto his work is what makes the authorship on main reflect who wrote it:

Author Commits
Benjamin Freeman (@Beetix) 26
Ola Adebayo (@olamide226) 15
Maintainer 5

Merging this lands #502, #526 and #561. All three should close with it.

What the base already does

Imported audio is a clip-anchored region on voiceover and music lanes, so a track travels with its clip through reorder, trim and delete. Fragments of one track share a trackId, and anchorAudioTrackFragments advances each fragment's offsetMs so a bed spanning a cut does not restart at it. Voiceover recording against the timeline, in a docked bar rather than a modal, with the timeline's own tracks silenced for the take. Fades, loop and mute, applied by an envelope in overlay_track_pcm and mirrored in the preview through one shared resolveFadeSecs. Native mixing on all three platforms.

Read #561 for the full account, including the correctness work found while testing it on a device — a track inside a trimmed stretch still playing, speed regions dragging audio with them, the ±12 dB clamp on per-track gain.

What was ported onto it

feat(ai): the agent can see and place audio. #561 landed the feature with no agent surface at all — absent from documentSnapshotForModel, absent from the tool roster. addAudio / setAudio, plus removeModifier resolving an audio track, built on this branch's own trackGroupId / collapseTracksToPills / patchAudioTrack / anchorAudioTrackFragments rather than a second set of helpers.

fix(timeline): keyboard activation on lane pills. Every pill carries role="button" and tabIndex={0} and nothing answered Enter or Space — pre-existing on main, and it affects all seven lane kinds, not only the audio ones.

fix(transcription): stop the background pass transcribing music. The auto pass queues every asset in the document. Measured with a four-minute bed: 35 seconds of whisper inference at editor open, 164 segments of transcribed music. assetCanCarrySpeech answers from the timeline, because AxcutAsset.kind only knows video | audio and the voiceover/music distinction lives on the track. This is the rule settled in #560.

perf(transcription): extract audio natively, off the UI thread. The freeze at editor open was never the inference — that runs in whisper-stt-server, its own process, at 6.9× real time. It was extractMono16kFromVideoUrl, which runs in the renderer: whole file into memory, an arrayBuffer() copy, a slice(0) copy, then a resample loop on the UI thread. ~86 MB of decoded float32 for that same four-minute bed, against 15.7 MB now that ffmpeg does it in the main process. Reuses the peaks path's resolveFfmpeg, which is what carries the Windows subtlety that the packaged binary is the shared build and not the static ffmpeg.exe the installer excludes.

feat(timeline): show what an audio pill crops, and let it slip. An audio pill is the only timeline object that edits media you cannot see. Two thirds of this turned out to be here already, in better form: the edges stop at the file's bounds via lowerLeft / maxEnd, with a loop exception that lets a repeating track outrun its source. What was added is the part that makes the stop legible — a dimmed ghost of the rest of the file around the pill (clamped to the programme, so a four-minute bed under a five-second view is a bounded element and not one tens of screens wide), an in → out / length readout while an edge is pulled, and Alt-drag to slip: the media slides under a span that does not move, at a rate derived from the file rather than the timeline, because an edge drag at timeline scale means dragging three minutes of ruler to reach 3:00 inside a four-minute bed.

Related issue

Closes #350. Supersedes #502, #526, #561. Refs #560, #540.

Type of change

  • Feature
  • Bug fix
  • Performance

Release impact

  • Minor

Desktop impact

  • Windows
  • macOS
  • Linux

Testing

  • npm run test2398 passed, 4 skipped, 0 failed (191 files)
  • npx tsc --noEmit and npx tsc -p tsconfig.test.json --noEmit — clean
  • npm run lint (Biome) — clean (16 warnings, all pre-existing on the base)
  • CI green on every push to the branch

Not done: the export smoke test on real macOS/Windows per AGENTS.md. #561 was built and exercised on macOS (arm64); the ports above were written and run on Windows. The native extraction has not been exercised at runtime on Linux — the path is platform-agnostic (spawn + pipes) and shares its ffmpeg resolution with a peaks path already in production on all three, but that is an argument, not a run.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added imported audio tracks with trimming, gain, fades, looping, muting, waveform lanes, drag/resize placement, and track controls.
    • Added audio-file import and microphone voiceover recording with keyboard shortcuts.
    • Audio tracks now play in previews and are included in exported videos.
    • Added audio asset support across projects, clipboard actions, agent tools, and browser mode.
    • Improved transcription with direct audio-file processing and reduced unnecessary music-track transcription.
  • Documentation
    • Added audio-track model and export workflow documentation.
  • Localization
    • Added audio-related translations across supported languages.

Beetix and others added 30 commits August 24, 2026 22:56
Phase 1 of issue #350 (import voiceover / BGM / SFX). Adds the document
model for timeline audio tracks without any UI, IPC, or export wiring yet.

- Widen assetSchema.kind to enum(["video","audio"]) so an imported audio
  file (no video stream) has its own kind. Additive — every existing doc
  holds "video", which still validates, so no schemaVersion bump.
- Add audioTrackSchema: a timeline-global track addressed in OUTPUT
  (post-trim/post-speed) timeline seconds, the same domain the compositor's
  concatenated programme PCM lives in. That invariant is what will keep the
  live preview and the export in sync in later phases.
- Add document.audioTracks[] (defaulted, so pre-#350 docs load unchanged),
  the AxcutAudioTrack type, and a createAudioTrack factory.
- Tests cover defaults, trim/position validation, factory round-trip, the
  kind widening, and that a document omitting audioTracks defaults to [].
- Fixture fallout: 15 test files + browserShim build full AxcutDocument
  literals and now carry audioTracks: [] alongside their zoomRanges: [].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 of issue #350. Wires up picking an external audio file (voiceover
/ BGM / SFX) and adding it to a project as a kind:"audio" asset. No
timeline placement, preview, or export yet.

- IPC: open-audio-file-picker mirrors the video picker but approves against
  a dedicated audio extension set (mp3/wav/m4a/aac/flac/ogg/opus). Factor the
  path approver into a shared approveReadableMediaPath so the audio and video
  approvers differ only by their extension gate — an audio picker must not
  approve a video path or vice versa.
- document-service.addAsset takes a kind; an audio import validates against
  audio extensions and never claims the empty primaryAssetId slot, so a BGM
  file dropped into a fresh project can't become its primary (video) asset.
  Threaded kind through the bridge chain (contracts, client, nativeBridge,
  aiEditionService) and the browser shim.
- projectStore.addAudioAsset imports the file, skips the camera-sidecar
  lookup addAsset does, and probes the real duration up front (new
  probeAudioDuration, the <audio> counterpart of probeVideoDuration) so the
  timeline can size the track in a later phase.
- i18n: selectAudio / audioFiles dialog strings across all 13 locales
  (English placeholders for the untranslated 12).
- Tests: document-service audio branch (kind, primary guard, extension
  routing), probeAudioDuration (shared harness, driven per media tag), and
  addAudioAsset (bridge kind arg, no camera lookup, duration stamping).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3 of issue #350. Adds the mutations that place and edit imported
audio tracks on the timeline. Still no UI or preview/export — that's next.

- New pure module document/audioTracks.ts: append / remove / move / trim /
  gain / mute, each taking an AxcutDocument and returning a new one. Audio
  tracks aren't clip-anchored (they float over the assembled programme in
  output-timeline seconds), so these are plain array edits with schema-valid
  guards — negatives floored, trimEnd pulled up to trimStart, NaN → 0.
- useTimeline wraps them: addAudioTrack looks up the audio asset, places the
  head at the playhead (output time) by default, and returns the new track id
  for the UI to select; move/resize/gain/mute/remove each commit one history
  step. Refuses a non-audio or unknown asset.
- Tests: the pure ops (immutability, guards, isolation) and the hook wiring
  (asset lookup, playhead placement, save, undo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 4a of issue #350 — the first user-visible slice. Import an external
audio file and it lands on a timeline lane you can select and adjust.
Drag-to-move and edge-trim are deferred to a follow-up (4b); reposition is
via a numeric offset field in the inspector until then.

- Media panel gains an "Import audio" button next to "Import media"
  (openAudioFilePicker -> store.importAudioAsset), which adds the asset and
  places a track at the playhead in one action.
- Selection lives in the project store, not useTimeline's local state,
  because the media panel and the inspector are in different subtrees and
  both touch it; region/clip selection stays hook-local. The hook delegates
  addAudioTrack to the store and reads selection from it.
- V4Timeline renders an audio lane (shown once a track exists) with a teal
  pill per track: the ClipWaveform reused as a background, windowed to the
  track's trim and scaled by its own gain, plus a label and mute glyph.
  Click selects.
- The inspector shows an AudioTrackPane (volume / mute / start-offset /
  remove) in place of the facet when a track is selected, the same
  precedence a region selection gets.
- i18n: importAudio / couldNotAddAudio (editor) and an audioTrack block
  (settings) across all 13 locales.
- documentWriteAudit gains rows for the seven new save sites (two store,
  five hook), each classified by trigger; this audit should have been run in
  phases 2-3 and now is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 4b of issue #350 — the audio lane is now interactive. Grab the pill
body to slide the track, the edge handles to trim: left moves the in-point
(and the head, so the right edge stays put), right moves the out-point,
capped at the source length.

- New setAudioTrackPlacement pure op writes position and both trim points in
  one shot, so a left-edge drag (which changes timelineStartSec AND
  trimStartSec together) commits as a single undo step. Hook wrapper
  placeAudioTrack; documentWriteAudit row added.
- startAudioDrag mirrors the region pills' drag: a local preview during the
  gesture, the same PILL_SNAP_PX magnet to clip boundaries and timeline
  ends, and one document write on pointerup. AudioLanePill grows two resize
  handles and moves on a body grab; selection happens on pointer-down.
- Tests: the placement op's guards, and the drag itself (pointer→second
  math, single commit, in/out-point semantics) driven through the geometry
  harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 5 of issue #350 — imported audio is now audible while editing. Each
track plays over the video, positioned on the RAW virtual timeline where it
was placed, at its own level.

- VirtualPreview mounts one <audio> per track and syncs it in the existing
  60Hz rAF loop: position from resolveTimelineAudioPlayback (playhead −
  timelineStart, offset by the trim in-point), play only inside the track's
  window, pause outside or when muted, and match the video's playbackRate so
  a speed region keeps A/V together. Level is the track gain × the global
  output gain via element.volume — deliberately NOT a WebAudio node, so the
  delicate primary/supplemental graph is untouched; a boost past 0 dB clamps
  in the preview but is still written to the export.
- Threaded audioTracks + audioSources through Preview → PreviewCanvas →
  VirtualPreview. videoSources already resolves a URL for every asset, so it
  doubles as the audio source list (looked up by assetId); both props
  default to empty, so a project with no imported audio is unchanged.
- A note on the coordinate system: tracks live on the RAW/document timeline
  (where addAudioTrack seeds timelineStartSec from the playhead), not the
  trim-compressed output timeline — corrected the Phase 1 comment's claim.
  The export will mix on the same RAW positions (Phase 6).
- Tests: the sync math (window, trim offset, mute, untrimmed tail) and an
  rAF-driven integration test that the loop seeks + plays/pauses the element.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 6 of issue #350 — imported audio now lands in the export, not just the
preview. The native compositor mixes each track over the assembled programme.

- audio.rs::mix_external_tracks overlays each track between
  assemble_concatenated_pcm and finish_audio: its trim window is decoded
  through the same decode_clip_audio path a clip's audio uses (48 kHz stereo),
  scaled by the per-track gain (the same 10^(dB/20) law as finish_audio), and
  summed in at its startSec offset. A track past the video end is truncated so
  audio and video stay the same length. The placement/gain/clamp math is split
  into overlay_track_pcm and unit-tested without ffmpeg (cargo test, verified
  on Linux).
- scene.rs gains SceneAudioTrack + Scene.audio_tracks (a separate field, so
  SceneAudio stays Copy and the pipelines keep copying it out of a borrow).
  Wired into all three pipeline_{linux,macos,windows}.rs.
- buildSceneDescription resolves each track to { path, startSec, gainDb,
  trimStartSec, trimEndSec, mute }. startSec is the raw timeline position —
  exact without trims/speed, an accepted approximation otherwise (the preview
  approximates trims the same way); trimEndSec is always concrete (the
  compositor preallocates the decode window from it). resolveSceneAssetPaths
  round-trips the JSON so the new field reaches the addon untouched.
- Corrected the Phase 1 schema comment (tracks live on the RAW timeline, not
  output time) and documented the mix step in export-pipeline.md.

Verified: compositor builds + `cargo test --lib audio` (14) pass on Linux;
tsc (app+test), biome, and the scene-description tests (95) pass. NOT yet
verified: the addon (.node) must be rebuilt with build:native:compositor:linux
and an actual export listened to — the manual E2E this phase requires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses review feedback on #350: a separate "Import audio" button was
both undiscoverable (added to only one of three media surfaces) and worse
UX than just letting "Import media" take audio too.

- open-video-file-picker is now a combined media picker: it offers video AND
  audio, approves whichever was chosen (video path first, then audio), and
  returns `kind` so the renderer routes an audio file to importAudioAsset
  (asset + timeline track) and a video file to addAsset (clip).
- All three import surfaces route by kind: MediaStage (the main media view),
  MediaPane (chat side panel), and EditorEmptyState. The standalone "Import
  audio" button and its handler are removed.
- Dropped the now-dead open-audio-file-picker IPC, its preload method/type,
  and the importAudio / couldNotAddAudio / selectAudio strings; added a
  mediaFiles dialog string across all 13 locales. approveReadableAudioPath
  and the audio extension set stay — the combined picker uses them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two issues from testing the #350 import:

- Jitter: the preview re-seeked each imported track whenever it drifted
  >25 ms from the playhead. The primary/supplemental audio can use that
  tight leash because it syncs to the <video>'s own authoritative clock; an
  imported track syncs to virtualTimeSec, which is DERIVED from that clock
  each frame and slightly noisy, so at 25 ms it re-seeked most frames and
  each seek briefly stalled the element — the jitter. Widen the leash to
  300 ms while the element is playing (it free-runs in sync from the right
  offset; the wide leash only catches real scrubs / trim jumps), keeping the
  25 ms leash for the paused/seek case. Music beds don't need frame-tight
  sync — that's the video's job.

- Audio shown "along the recording": handleDropAsset (the media stage's
  "Add to timeline" button and drag) ran insertClipAt for ANY asset, so
  adding an imported audio asset built a video-style clip in the clip row on
  top of its lane track. An audio asset has no video and must never become a
  clip: route it to addAudioTrack instead, and reuse its existing track so
  the same file can't stack duplicate lanes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reworks the #350 import UX per testing feedback: the media tab arranges
video CLIPS (it chains them), which is the wrong model for an audio overlay.
Audio is now added the way an annotation is — a timeline action.

- New "Add audio" tool in the timeline toolbar (music icon, next to
  zoom/speed/camera): opens an audio-only picker and places a track at the
  playhead via importAudioAsset.
- The media tab is video-only again: open-video-file-picker reverts to video
  extensions, restored the dedicated open-audio-file-picker for the toolbar,
  and MediaStage / MediaPane / EditorEmptyState import video only.
- Audio assets are hidden from the media lists (MediaStage + MediaPane) —
  they're managed on the timeline lane (select the pill to edit/remove), so
  they never appear as chainable clips.
- i18n: audioTrack.add / importFailed, restored selectAudio, dropped the
  now-unused mediaFiles, across all 13 locales.

The handleDropAsset guard (audio → track, never a clip) stays as a backstop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Testing feedback on the #350 audio UI:

- Move the "Add audio" toolbar button left of the first divider (grouped with
  auto-enhance, ahead of the region tools) instead of isolated at the end.
- AudioTrackPane: header is now the generic "Audio track"; the file name
  moves into the body. Drop the mute button and the start-offset field
  (position and mute are handled on the lane), leaving volume + delete. The
  delete button now matches the region panes' danger-outlined style.
- Remove the now-unused audioTrack.offset / mute / unmute strings across all
  13 locales and correct the help text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
More #350 UI feedback:

- Move the "Add audio" toolbar button to directly right of "Add annotation"
  (the comment tool), rendered inside the tool row via a Fragment.
- Rename the inspector's "Remove track" to "Delete track" and make the button
  full-width, matching the region panes' delete button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per #350 feedback: label the slider "Output level" (reusing audio.outputGain,
the same string the global Audio pane shows) and add a "Reset audio" button
that zeroes the track's gain, styled like the global pane's reset. Drop the
now-unused audioTrack.volume string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
moveAudioTrack and resizeAudioTrack lost their only callers when the
inspector's offset field was removed; the lane's edge-drag commits position
and trim together through placeAudioTrack (setAudioTrackPlacement), so the
separate position-only and trim-only ops were dead. Remove the two hook
wrappers, the two pure document ops (moveAudioTrack, setAudioTrackTrim),
their tests, and their document-write-audit rows. placeAudioTrack /
setAudioTrackPlacement stay and still cover both edges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mute button was removed during UI review, leaving mute reachable nowhere
— a working-but-unsettable flag with a dead branch in the Rust mixer. It was
added on this branch and never shipped, so it comes out cleanly with no
schema migration. Volume (down to -12 dB) plus delete cover the need for a
simple audio overlay; a mute+solo pass can come back as its own feature.

Removed end-to-end: audioTrackSchema.mute, setAudioTrackMute /
toggleAudioTrackMute, the pill's mute glyph + .laneAudioMuted, the preview's
mute gate, the scene's mute field (TS + scene.rs), the mixer's mute skip,
and every test that exercised it. Rust (cargo test --lib audio, 14) and TS
(1049 across ai-edition + native) pass; compositor addon rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 7 leftover for issue #350: the top-level-shape table enumerated every
other document array but not the new audioTracks[]. Add the row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Correctness / data integrity:
- audio.rs: bound the decode window to the programme remainder and skip a
  track that starts past the end, so a long track pinned near a short
  export can't buffer hours of PCM (then discard it).
- document-service.removeAsset: pass primary to the next VIDEO asset, never
  an audio overlay, and drop audioTracks that referenced the removed asset.
- useTimeline: backfill a missing audio duration on load (a failed
  import-time probe otherwise leaves durationSec 0 → a zero-length, never-
  playing window), mirroring the video-dimension backfill.
- projectStore.importAudioAsset: report failure when track placement fails,
  instead of claiming a successful one-shot import with no track.
- V4Timeline: clamp the drag so a track's head/tail stay within the
  programme (no pill past 100%, matching the export's truncation).
- useTimeline: clear region/clip selection after a successful audio-track
  insert (no concurrent selections); clear the inspector selection only
  AFTER a delete commits.

Tests / quality:
- Remove a new `any` cast in projectStore.test (use vi.mocked).
- Translate the audioTrack / selectAudio / audioFiles strings in all 12
  non-English locales.
- Add coverage: removeAsset audio cases, the duration backfill, browser-shim
  audio import, and the decode-bound skip (Rust).

Deferred with rationale (noted on the PR): schema v8 bump (audioTracks
follows the repo's additive-no-bump precedent, transcriptionFailure);
positive-gain preview via WebAudio nodes (heavy, risks the audio graph);
moving audioTracksRef to an effect (matches the file's existing render-ref
idiom).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`element.volume` is spec-clamped to [0, 1], so a track pushed above 0 dB
played at unity in the preview while the export mixed it at full boost —
the preview under-represented exactly the tracks a user deliberately turned
up. Route each mounted track element through its own WebAudio gain node
(source → trackGain → the existing output gain → destination), the same
node type the primary/supplemental sum already uses to boost past 0 dB. The
rAF sets each track's gain live, so a slider drag is picked up without
rebuilding the graph; the effect re-routes only on a real mount/unmount
(keyed on the resolved-track set, not the gains). The `.volume` path stays
as the fallback for when WebAudio is unavailable (jsdom, a denied audio
policy), where a boost still caps — audible, just not amplified.

Effective level is trackGain × outputGain, matching the exporter's order
(mix_external_tracks applies the track gain, finish_audio the output gain).

Adds a preview test that stubs AudioContext and proves a +6.0206 dB track
(×2) drives its gain node to 2 rather than clamping element.volume to 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The audio lane was the only timeline lane hidden until it had content, on
the premise (in a now-stale comment) that audio is imported from the media
panel "not a keystroke". Audio is a toolbar peer of the region tools now, so
give it what they have: the lane always renders and, when empty, advertises
the shortcut that fills it ("Press M to add audio") — exactly like the zoom,
trim, annotation, speed, and camera lanes.

Register `addAudio` on M (a free, mnemonic key) in the shortcut config so it
shows in the Shortcuts dialog and is user-rebindable, and handle it in the
editor shell. Unlike its neighbours it opens a file picker rather than
dropping a sized region at the playhead, so it takes no duration.

Lift the picker→import flow out of the timeline toolbar into
`useTimeline.addAudio` so the button and the shortcut share one path; the
toolbar button now calls `tl.addAudio()`.

i18n: add pressAudio / actions.addAudio across all 13 locales.
Tests: addAudio wiring (picker → import, cancel → no-op); shortcut-label
parity still holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…350)

An imported audio track stores its head in RAW timeline seconds (seeded from
the playhead), but the exporter mixes it onto the trim-COMPRESSED programme.
The scene builder passed the raw head straight through as the output offset,
so every cut ahead of a track delayed it in the render by exactly the removed
duration — the reported "the following audio track was delayed by the trim
duration once rendered". The preview never showed this because its playhead
jumps across a trim, landing the track on time.

Project the raw head onto the programme before handing it to the compositor:
output(T) = T − (trimmed span before T), a new pure
`projectRawTimelineSecToPlayback` that walks clips+trims with the same
source-time model as `resolvePlaybackSegments`. Exact for trims; speed
regions remain the pre-existing approximation. Regions with no trim — a
project with no clips included — pass through unchanged.

Corrects the two stale comments that claimed the raw positions "agree" with
the export (they only did without trims). Adds unit coverage for the
projector (before/after/inside a cut, multi-clip) and an end-to-end scene
test pinning a track's head onto the compressed programme.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three findings on the recent commits:

- timeline.ts (Major): `projectRawTimelineSecToPlayback` accumulated each
  matching trim independently, so OVERLAPPING trims were double-counted and
  RAW GAPS between clips were ignored — e.g. trims [2,5]+[3,4] mapped raw 6 to
  2 instead of 3, and a track after an inter-clip gap landed late. Rebuild the
  projection from the SAME kept intervals as `resolvePlaybackSegments`
  (per-clip `subtractInterval`, shared output cursor), so the union of trims is
  counted once and gaps are removed exactly as the programme removes them. A
  raw head past the last kept frame still carries its overhang through, keeping
  the no-clips case an identity. Adds overlapping-trim and gap regressions.

- useTimeline.addAudio (Minor): the import goes through the store's
  importAudioAsset, bypassing the hook's selection reset, so a region/clip
  selection could survive an import. Clear selection/multiSelection/
  clipSelection on success (the same exclusivity addAudioTrack keeps).

- useTimeline.addAudio (Minor): move the openAudioFilePicker await inside the
  try so a rejected picker reaches the localized toast instead of an unhandled
  rejection; a cancel stays a silent early return.

Adds tests for the selection reset and the picker-rejection toast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…, not output

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… export

`resolveTimelineAudioPlayback` derived the track's source position from the RAW
playhead, which JUMPS across an interior trim — so the `<audio>` element skipped
that much of the file and the track ended early. The exporter's
`audio::mix_external_tracks` instead overlays the decoded window
`[trimStart, trimEnd]` contiguously at its projected offset, cutting nothing.
The two disagreed silently: a trim inside a track's span desynced preview from
export by the removed duration (Etienne's 10s-clip / raw-2..4-cut example: 2s).

Move the function to OUTPUT-programme space — it now takes the playhead and the
track head already projected through the trims, and the rAF projects both with
`projectRawTimelineSecToPlayback`, the same function the export uses in
sceneDescription. `local` is then continuous, so the track plays as one block
exactly as the export mixes it. Adds a regression test on Etienne's scenario
asserting source position 3 (not 5) at output time 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…aveforms survive a reopen

`get-audio-peaks`, `read-binary-file`, `get-readable-file-info` and
`read-file-chunk` gated on `approveReadableVideoPath`, whose extension allowlist
is video-only. On first import the picker approves the exact path, so the
waveform draws; but after a project reopen `approvedPaths` is empty, an imported
audio file outside RECORDINGS_DIR (e.g. ~/Music/bgm.mp3) fails
`hasAllowedImportVideoExtension`, the handler returns `{success:false}`, and
`useAudioPeaks` caches that as "no audio" — losing the waveform for good.

These four handlers serve whichever media the document points at, so gate them
on a combined `hasAllowedImportMediaExtension` (video OR audio) via a new
`approveReadableAvPath`. The type-specific import pickers are untouched and stay
honest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Speed regions no longer retime imported audio in the preview. The rAF forced
  each track's playbackRate to the video's, so a voiceover under a 2× region
  played pitched-up and finished early — but `mix_external_tracks` sums the track
  at 1× (speed stretches clip PCM only). Pin imported elements to 1×.

- The voiceover play path now resumes a suspended AudioContext, as the primary
  loop already does. A track starting while the primary element is silent (span
  over, or a recording with no separate audio) otherwise routed into a suspended
  context and played nothing, with no error.

- The raw→output projection for a track's head now walks the same path-filtered
  clips the programme is assembled from (`resolveVisibleClips`'s filter). Walking
  the full `document.timeline.clips` counted a relinked-away clip the programme
  omits, landing every following track past the real programme end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Deleting an audio track now also drops its orphaned asset. An imported audio
  asset is only reachable through its track (audio never becomes a clip), so a
  delete left it in the document forever, invisible in every asset list.
- Clear a stale `selectedAudioTrackId`. An undo can remove the selected track
  without going through `removeAudioTrack`, leaving the inspector open on an empty
  AudioTrackPane recoverable only by clicking a facet; a small effect resets it.
- Reset the volume pane's `liveGain` when the selected track changes, so an
  uncommitted drag on one track doesn't display as the next track's gain.
- Reset `audioDragRef` at the start of a lane drag, so a select-click landing
  while a prior drag's `placeAudioTrack` is still in flight can't re-commit it.

Adds removeAudioTrack asset-cleanup tests (orphan dropped; shared asset kept).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Dedupe probeVideoDuration/probeAudioDuration into one tag-parametrized
  probeMediaDuration. They were byte-identical but for the element tag; every
  property touched is on HTMLMediaElement, so a settle/cleanup/timeout fix can no
  longer drift between them. The two exports stay, so callers/tests are unchanged.

- Mark every audio-backfill candidate probed BEFORE the first await. Marking each
  only as its turn came let a document change that re-entered the effect mid-probe
  re-probe the still-queued assets.

- Scale the audio-lane waveform by the track gain AND the project output gain, as
  the export's finish_audio does (it applies both and clamps). Scaling by the
  track gain alone under-drew a boosted output, hiding clipping the file will have.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings #526's clip-anchoring model onto #502, per review on #526. #502
keeps its document shape, its native mixer and its output-space preview;
audio tracks stop floating at an absolute raw second and travel with the
content they were placed over through reorder, trim and delete.

- `audioTrackSchema` restated on the v5 clip-anchor contract:
  `{startMs, endMs, ...clipAnchorShape, offsetMs, gainDb, …}`. `offsetMs`
  replaces the `trimStartSec`/`trimEndSec` pair — the track's own span
  already says where it stops, so the tail trim no longer needs storing
  twice.
- `audioTracks` joins `mapAllRegionCollections`, `RegionKind` and
  `removeRegion`, so every structural clip edit re-derives audio the way it
  already re-derives zoom and annotation, and a track can be copied and
  pasted like any other pill.
- `document/audioTracks.ts` drops its hand-rolled array ops for the shared
  pill helpers; the lane renders `collapseTracksToPills` instead of one row
  per stored track.

The fragment problem, which the review called out as unsolved in both PRs:

`anchorRawRegionsToClips` copies a region's payload verbatim into each
fragment. That is right for value-per-span effects — both halves of a split
zoom are still "depth 3" — and wrong for continuous media: two fragments
each holding `offsetMs: 2000` both restart the file two seconds in, so a bed
spanning a cut audibly restarts at the boundary.

`anchorAudioTrackFragments` advances each fragment's `offsetMs` by the
source time its predecessors consumed, so the pieces play as one continuous
take. Fragments share a `trackId`: the lane collapses them to one pill, the
inspector edits the group, and delete takes the group.

Also folds the path-resolvability rule that decides which clips make the
programme into one predicate shared by `resolveVisibleClips` and the audio
projection, rather than two copies that could disagree — the review's
`audioLayerTimeline.ts` point, landed one level down from where it pointed:
`resolveVisibleClips` returns trim-COMPRESSED segments, and
`projectRawTimelineSecToPlayback` subtracts the trims itself, so feeding it
those would apply them twice.

Claude-Session: https://claude.ai/code/session_01DvB2GJXp18fPr79DHfk356
Adds the payload the anchored track schema was missing: `fadeInMs`,
`fadeOutMs`, `loop`, `muted` and a `kind` discriminator, wired through the
inspector, the preview and the native mixer.

- The inspector gains fade-in, fade-out, mute and loop next to the existing
  volume slider. Fades cap at 5s there; past that a fade reads as a level
  change rather than a fade.
- `anchorAudioTrackFragments` keeps `fadeInMs` on the first fragment and
  `fadeOutMs` on the last, so a track split by a cut fades once at each real
  edge instead of at every boundary. Looping tracks are exempt from the
  offset advance: they fold within `duration - offset`, which every fragment
  shares, so advancing would drift them out of phase with the mix.
- The scene builder drops muted tracks and emits one mix entry per repeat
  for a looping one, carrying the fades only on the pieces that touch the
  track's real edges.
- Fades reach the compositor as `fadeInSec`/`fadeOutSec` and are applied by
  a new envelope in `overlay_track_pcm`, measured against the DECODED length
  so a track truncated at the programme end does not ramp down over audio
  the render never reaches.
- `resolveFadeSecs` reduces fades that do not fit their span — in proportion
  rather than clamping each independently, which would turn an asymmetric
  pair symmetric. An unreduced fade-in longer than the span is worse than
  cosmetic: it holds the gain at zero for the whole track. Mirrored by
  `resolve_fade_samples` in `audio.rs` so the preview and the render cannot
  disagree about how a fade gets shortened.

Two things found while wiring this up:

- `mix_external_tracks` clamped per-track gain at ±12 dB, but that is the
  project OUTPUT trim's range; the track schema's own is -60..+12. Every
  quiet bed was floored at a tenth of the attenuation asked for. Widened,
  with a test — the existing clamp test covers `finish_audio`, a different
  path, still bounded at ±12.
- Two `SceneAudioTrack` fixtures construct the struct literally and needed
  the new fields to keep compiling.

Claude-Session: https://claude.ai/code/session_01DvB2GJXp18fPr79DHfk356
The one audio gesture #502 has no equivalent of: it is import-only. Music
and other files keep coming in through the toolbar's `addAudio` file
import, so this dialog exists only for recording, which has live state to
show.

- `save-recorded-voiceover` IPC writes the MediaRecorder blob under the
  recordings dir, so a take outlives the session like any other asset.
  Capped at 512 MB — this writes renderer-supplied bytes straight to disk,
  and an hour of Opus is a few tens of MB, so the cap refuses a runaway
  payload without ever being reachable by a real take.
- `V` records a voiceover from the playhead. The video plays while the take
  runs so the user can narrate what they see, and recording stops itself at
  the end of the timeline.

Two bugs the review found in this flow on #526, both fixed rather than
carried over:

- Every take landed one full take-length to the right of where it was
  spoken. Recording plays the video, so the live playhead advances for the
  whole take, and placement read it at the END. The shell now captures the
  playhead when recording STARTS — and re-captures on Record, since the user
  may scrub after opening the dialog.
- Tearing the dialog down mid-take (project close, shell unmount) stopped
  the microphone stream but never the recorder, so `onstop` never fired: the
  take was dropped and the video element left playing. The cleanup now stops
  the recorder and discards the blob, since nobody is left to place it.

Claude-Session: https://claude.ai/code/session_01DvB2GJXp18fPr79DHfk356
EtienneLescot and others added 29 commits September 2, 2026 15:15
A voiceover is speech. It has words, it sits on the timeline, and the transcript
tab could not see any of it — the tab was wired to `timeline.clips`, so the one
lane it could read was the one that came out of the camera. Importing narration
meant losing every tool the transcript gives you over it: trims, word edits, the
agent's grounding, captions.

The aggregation is now parameterised by lane. `TranscriptPlacement` is the unit
it actually runs over — one stretch of ONE asset's source time, laid somewhere on
the ruler. `AxcutClip` is one provider of that shape and was for a long time the
only one, which is why everything downstream is still named after clips;
`voiceoverPlacements` is the second.

Deliberately structural rather than a union of the two record types. Nothing
below the aggregator needs to know which lane a section came from, and the moment
it could ask, something would start behaving differently per lane — which is the
one thing this parameterisation exists to prevent. Sections, word ids, trim runs
and cue lookup are byte-for-byte what they were.

Three things the voiceover provider gets right that a naive map would not:

  - It windows the source by the FRAGMENT's offset. A track spanning a cut is
    ventilated into a fragment per clip, each with `offsetMs` advanced by what
    its predecessors consumed. Collapsing them back into one pill here would
    re-read the file from its head on the far side of every cut.
  - It leaves music out entirely, rather than filtering it downstream. Music is
    not transcribed at all, so a music placement could only ever produce an empty
    section that reads as a failed transcription.
  - It ignores `loop`. A transcript that says the same sentence three times is
    not a transcript of anything.

The switch shows up only when there is a voiceover to switch TO — a one-sided
control asks a question about a lane the project does not have. It renders in
both of the pane's states, and the active lane is DERIVED rather than reset in an
effect: deleting the last voiceover pill while reading it must not leave the pane
addressing a lane that is gone, and an effect would render that empty state once
before correcting it. `hasAnyTranscript` moves from the document to the lane, so
"imported a voiceover, not transcribed yet" says so instead of showing a blank.

Captions still render the recording lane. Making them follow the choice is the
rest of #560 phase 1, and it needs somewhere shared to put the choice — this one
is local because it has exactly one reader today.

Refs #560.
`setWordText` gave the transcript a way to be corrected, but nothing said a
word had been. Two consequences: a transcription run REPLACES the asset's
transcript, so a user who fixed twenty proper nouns and regenerated lost all
twenty without a word about it; and there was no revert, because nothing kept
what the transcriber had originally said.

`wordSchema` gains the pair that fixes both — `originalText`, captured the
first time a user rewrites the word and never overwritten afterwards, and
`source`, which also reserves the `"synth"` value a word with no audio behind
it will need. Both optional and absent from every document written before them,
like `cameraTrack.width`: additive, so no schema bump. `document/transcript.ts`
is their only writer and keeps them consistent — typing the original back
clears the pair, which IS the revert.

`carryOverWordEdits` then re-applies the corrections onto a fresh transcript,
and the transcription store calls it on the one path where a run lands. The
match is deliberately strict: same original text, overlapping span, one new word
per correction. A correction is carried only when the run reproduced the very
same mistake at the very same moment, so re-transcribing in another language
carries nothing rather than stamping the old language's corrections onto the new
words. What could not be carried is counted, not guessed at — saying so to the
user needs a string in thirteen locales and belongs with the editing UI, so for
now it is a warning in the log.

`withTranscript` moves here from `transcribe.ts` and gains `setDocumentWordText`
beside it. The document carries the same transcript twice (the per-asset entry
and the legacy `transcript` mirror), and writing one without the other leaves
the mirror serving pre-edit text forever — the failure that closed #469. It is a
pure document operation; the Whisper adapter was not the place to look for it.
The transcript pane looked like a document and behaved like one only halfway:
Backspace on a word wrote a trimRange and cut the media, while typing was
blocked outright — `handleBeforeInput` said so in a comment, and correcting a
mis-transcribed word had no in-app path at all.

The two gestures now share the one word stream, with no mode to remember and no
second tab: Backspace still cuts, and a double-click opens the word for editing
in place. Enter or a click away commits, Escape abandons. A correction writes
`transcript.words[].text` and nothing else — the captions follow it, the
timeline does not move. Everything the field raises is stopped at the field, so
a Backspace inside it types instead of cutting the clip out from under the
caret.

A corrected word says so: the accent colour, a dotted underline, and a tooltip
naming what the transcriber actually heard. Hovering it offers the revert, which
writes the original back through the same path that clears the provenance pair
— there is no second "unedit" operation that could fall out of step with the
first. It sits where the bin sits on a cut word, in the accent rather than the
danger colour, and the two never appear together.

Emptying a word is how a junk token leaves the captions without the audio going
with it, and the caption pipeline already drops empty words. Rendered as its own
text, though, such a word is a bare space: invisible, un-clickable, impossible
to undo. It gets a chip instead, so it keeps a place in the stream.

Writes go through `setDocumentWordText` on the same serialised queue as the
trims, so correcting a word and cutting the next one cannot overwrite each
other's save. Verified end to end in the browser preview: the edit lands on the
word, rebuilds its segment, reaches the legacy `document.transcript` mirror, and
the revert takes all three back.
The third gesture on the word stream, and the one that had to get past a guard.
Put the caret between two words, type, and a field opens beside the word you
were on; Enter turns it into a real word, in amber, marked `source: "synth"`.
It has no audio, so its own control deletes it rather than trimming — there is
nothing for a trim to remove — and Backspace over a run of nothing but inserts
does the same.

The guard it got past did not work. React 18 builds `onBeforeInput` from the
legacy `textInput` event, whose `TextEvent` has no `inputType`, so the block's
handler threw `Cannot read properties of undefined (reading 'startsWith')` on
every character typed, never reached its own `preventDefault`, and let the
character land in the contentEditable — the exact desynchronisation between the
DOM and `words` it was written to prevent. Confirmed in the browser before
touching it. It now listens to the native `beforeinput`, where the event really
is an `InputEvent`, ignoring what the two nested fields raise on their own.

An inserted word takes the silence it is dropped into and nothing else: from the
word it follows, up to what its text needs to be read at subtitle pace, and
never past the word that comes next. Between two words that run into each other
it has no duration at all and rides their caption line, which is where it reads
correctly anyway. Dropped into a pause it shortens the `[silence]` pill by
exactly what it took. That span is also the slot a synthesized voice will have
to fit, when there is one.

`wordsInRange` had to learn about a word with no span — an overlap test excludes
a point at either edge of the range, which silently lost every word inserted at
the very start of a clip — and `withSilenceGaps` now breaks the resulting tie on
array order rather than leaving it to sort stability nobody had written down.

A run also carries inserts forward now, not just corrections. They have no
original text to recognise, so time places them: each goes back after whatever
the new transcript ends last before it. `carryOverWordEdits` counts what it
could not place, as it already did for corrections.

Verified end to end in the browser preview: typing leaves the block's own text
untouched, the word lands in `words`, in `segment.wordIds`, in the rebuilt
segment text and in the legacy mirror, an insert into the silence shrinks the
pill from 2.6s to 2.2s, and deleting it puts the pill back.
…frame

Adding a word only borrowed whatever silence happened to be free, so a word
dropped between two words that run into each other got no time at all. It now
creates the time it needs: the clip splits at the word's edge and a held-frame
clip carries the deficit, so the timeline grows and everything downstream
shifts. Screen and webcam freeze together — both are derived from the one asset
source clock the freeze stops advancing — and the decoder is paused for the
duration rather than free-running past the held frame into what comes after.
That created span is the slot a synthesized voice will speak in.

The gesture is gated to dev builds until there is a voice to put in it. A silent
freeze frame is not a feature, and captions are not always on, so a release
build offers no way to add a word at all — the pane does not even advertise it.
Drop the gate in `openInsertion` when TTS lands.

Three things the split broke, found by running it rather than by reading it:

The captions went DARK over the pause. A line straddling the split was
ventilated once per half, each half carrying the whole line, so the caption
played, blinked out for exactly the pause the word exists for, then played again
from the top. A line covering the held moment now covers the pause too, and
spans that meet on the ruler coalesce — measured before and after: two cues of
"Bonjour on va parler de vraiment Kubernetes" became one, 0→3.9s.

The word rendered TWICE in the pane. The freeze claims it and the half that
starts at the same moment matched it as well. The freeze owns it: it is the
section the playhead is inside while the pause plays.

And one word turned one recording into three headed blocks, each announcing the
same filename over a sliver of timecode ("Clip 2 · 0:02.5—0:02.5"). Sections
that continue the one before — same media, meeting on both clocks — now flow
inline under a single header spanning the whole run. Two clips over one media
still get a header each, which is the case the header exists for.

The pane also says what its gestures are now: double-click corrects, Backspace
cuts, and (in dev) typing between two words adds one. They were invisible until
tried.

Tests cover the freeze end to end: the document split, playback keeping the
created time, the source clock held still through it, the pane showing the word
once, the header run, and the caption playing once straight through.
Splitting the clip to make room for an inserted word did not survive the rest of
the app. A project saved after two inserts came back with one clip named
`clip_..._fzA_fzA` — split twice, both freezes and both after-halves gone, the
full source range restored onto the mangled id — and zero synthesized words. The
inserted text was lost on save, and the held-frame clips that did exist were
skipped in playback.

That is not a bug to chase. Clip surgery for a caption-only feature puts the
timeline's shape under the transcript's control, and every other writer of
`timeline.clips` — the duration probe, the recording import, resequencing — is
entitled to disagree with it. `frozenSec` goes with it, and so do the readers
that had to special-case a clip whose source window is a single point: the
playback segments, the raw span, the native position clamp, the decoder pause,
the caption ventilation, and the pane's split-clip header run.

What stays is the part that was always true on its own: an inserted word is a
word in the transcript with `source: "synth"`, it borrows whatever silence is
free where it lands, and it reaches the captions. The gesture stays dev-gated —
now for a second reason, since without created time an inserted word can only
speak inside a pause that already exists.

Creating time is still the right answer once there is a voice to put in it. It
belongs in a record of its own, beside `trimRanges`, which is the inverse
operation and the one shape the timeline already threads everywhere.
… new

An added word was visible only in the transcript pane. On the timeline — where
you decide what the film does — nothing said a moment carried text with no
audio behind it.

Each one now gets a thin amber tick on its clip's own track, at the moment it
sits on, carrying its text in the tooltip and seeking to it on click. Amber is
the colour the pane gives the same word, so the two read as one thing.

Derived, never stored: the mark is computed from the transcript's `synth` words
on every render, so there is no second record to fall out of step with the
first, and nothing for another writer of `timeline.clips` to lose. Positioning
is a percentage inside the clip's own box rather than an absolute ruler offset,
so a mark travels with its clip through a reorder without arithmetic of its own.
The agent could read the transcript and cut it, and that was all. Asked to fix a
misheard name — the request the whole transcript-editing feature exists for — it
had exactly one tool that touched a word, `addTrim`, which removes the audio
along with it. It would either do the destructive thing or say it had no tool,
while the app had had the operation for three commits.

`setWordText` writes `transcript.words[].text` through the same document
function the pane uses, so the captions follow and the timeline does not move.
Empty text blanks the word, which is how a junk token leaves the captions
without cutting the speech around it. It is registered as a mutating tool: it
writes the document, so it passes the consent gate like every other edit.

It needed a read to address anything. `getTranscript` answers in SEGMENTS, whose
ids live in a different namespace than the words and are refused by name — the
trap a model would fall into first, so the refusal says which read hands out the
right ones. `getTranscriptWords` is that read: id, text, span, and — only when
the word is not plain transcription — where it came from and what the
transcriber had originally heard. It takes a span, because a half-hour
transcript is ~70k tokens and fixing one name should cost one phrase.

Inserting a word is deliberately NOT exposed. The gesture is dev-gated in the UI
until a voice can be synthesized for it, and handing the model a tool for
something a release build refuses to do would be the same dead affordance the
pane was careful not to advertise.
Adding a word only ever borrowed the silence that happened to be free where it
landed, so a word dropped between two words that run into each other got no time
at all — nothing to see on the timeline, nothing for a voice to speak into.

The pause is now a record of its own: `timeline.insertRanges`, anchored in
source time, shaped like a trim and stored for the same reason. It is the
inverse operation, and a region is the one shape this timeline already carries
safely from end to end. The first attempt made CLIPS for it and lost both the
pauses and the words they belonged to, because every other writer of
`timeline.clips` — the duration probe, the recording import, resequencing — is
entitled to disagree with a clip it did not make.

Stored, but with one writer and one invariant. `withInsertRangesForWords` runs
after every word write and is the only thing that touches the array: it adds the
pause an added word needs, resizes one whose text changed length, and drops the
ones whose word is gone. `insertRangesMatchWords` is that same rule read back,
so a test holds the writer to it rather than trusting it. A pause under 50ms is
not stored at all — a few frames of held image is a stutter, not a slot.

`timeline/inserted-time.ts` is the arithmetic the readers will need, pure and on
its own: where a pause lands on the ruler once projected through the clip that
plays its moment, and the pair that converts between stored raw seconds and the
seconds the user actually scrubs. They are inverses everywhere except inside a
pause, where a stretch of ruler stands for one held source moment — so the
collapse answers with that moment AND says it is being held, which is what a
caller driving a decoder needs to park rather than seek.

Nothing reads it yet. The ruler, playback and the captions come next; this is
the record and the arithmetic they will share.
The record and the arithmetic existed; nothing consulted them, so the added time
was real in the document and invisible everywhere else. Now every reader that
measures the timeline measures the same expanded ruler.

Playback holds the frame. `resolvePlaybackSegments` cuts the kept span open at
each pause's moment and puts a held segment between the halves — the stream
plays up to that frame, stays on it, then carries on, which is what makes the
film longer. It carries `heldSec` on `PlaybackSegment`, a type that exists only
on the derived shape: nothing can write the field to a stored clip, which is the
whole structural difference from the attempt that made clips for this. A pause
whose moment a trim removed is never emitted — that moment is not in the film
any more, so neither is its pause.

The decoder holds with it. `resolveNativePosition` clamps the source clock
inside a held segment, and the transport pauses the native side for its
duration; free-running would play what comes after while the app clock, which
does traverse the pause, re-seeks on the drift and stutters. Screen and webcam
hold together because both derive from the one asset source clock the pause
stops advancing.

The ruler counts it. Total, ticks, clip boxes, lane pills and the playhead are
all placed through `expandRawSec`, so nothing drifts from anything else by the
added time. The amber mark on the clip becomes a BAND exactly as wide as the
time it bought — a word that fitted in silence already there adds nothing and
stays the hairline it was. Stored clip geometry is never rewritten for any of
this; only what is drawn moves.

The captions follow. Expanding both ends of a line's ruler span does the whole
job: a line after a pause slides along by it, and a line covering the held
moment has only its end pushed out, so it stays on screen through the pause
instead of going dark over the one moment an added word exists for.
#570 (word edits) and #569 (imported audio) both rewrite the transcript pane, and
#560's lane selector sits on top of both: the tab has to know which lane it reads
before a double-click can correct a word in it. Testing either alone stopped
being useful.

Merged rather than rebased. This branch already carries merge commits, so a
rebase flattens them and replays conflicts that were resolved weeks ago — which
is exactly what a first attempt did, re-adding a `MediaList` to LeftPanel that
`feat(editor): add audio from the timeline toolbar, not the media tab` had
deliberately taken out.

Where the two met:

  - The transcript pane. #570's `<Pane>` structure wins — it owns the gesture
    hint and the word-edit callbacks. #569 contributes the caption-settings
    action and the lane switch, which goes ABOVE the hint so the hint always
    describes the stream directly under it. The empty-state guard becomes
    `placements.length`, so it answers for the lane being read rather than for
    the recording.
  - The locale files. Merged as OBJECTS, not as text: both branches append keys
    to the same blocks, so every one of those 26 conflicts was a union git could
    not see, and hand-editing them is how a dropped comma or a doubled key gets
    in. `transcript.help` takes #570's copy — it is the one that mentions
    double-click, which now exists.
  - Fixtures. Each branch's pane tests learn the other's props, and the audio
    lane fixture gains `transcripts: []` for the amber added-word marks.
Everything that reads the timeline answered that question its own way, and the
answers disagreed. The transcript pane asked it by IDENTITY — does a trim name
this clip — which is a question a voiceover placement can never answer yes to:
it carries an audio fragment id and an audio asset, while every trim carries a
video clip. So the voiceover lane read every word as kept, including words whose
moment had been cut out of the film, and a cut authored from that lane removed
nothing at all.

The fix is not a better identity test. A trim is a removed span of the RAW
RULER, and both lanes lie on that one ruler: a word is removed if and only if
the raw moment it occupies is. `programme-time.ts` is that reading —
`keptRawSpans` / `removedRawSpans` / `removalAt` / `subtractRemoved`, derived and
never stored. Storage does not move: a trim stays source-anchored to a clip.

`keptRawSpans` is LIFTED out of `projectRawTimelineSecToPlayback`, which now
calls it, rather than reimplemented beside it — so agreement with playback is by
construction. Deriving it from `trimToTimelineSpan` instead would have been the
trap: that function's un-anchored branch resolves a pre-v7 trim through the
first clip whose source range contains its START, while the playback walk cuts on
OVERLAP across every clip of the asset. A primitive built on it would have left
the second clip's words reading kept over film that is gone — a new bug on the
lane that is correct today. There is a test named after exactly that.

Two boundaries do not follow from the definition and are pinned:

  - The programme ends at the last CLIP's raw end, not the last KEPT span's. A
    trimmed tail is genuinely removed; raw time PAST every clip is not removed
    but unfilmed, because the projection is the identity there. That is what
    lets a voiceover hang off the end and keep playing.
  - A gap between clips is removed with NO trim ids. Nothing plays there, so a
    word over it is not in the film — but there is no pill to restore, and a
    caller offering that affordance must key it on `trimIds` being non-empty.

`Interval` and `subtractInterval` move to `timeline/intervals.ts` and are
re-exported, because the dependency runs document/ → timeline/ and importing
back would close a cycle. A second copy of those twelve lines was the
alternative, and two implementations of "what survives a cut" is the shape of
bug this change exists to remove.

The randomised fixture checks the SUM against `resolvePlaybackSegments`, which is
blind to order — so it also projects every span's head and expects the output
length of everything before it, which only holds if the walk yields them in
playback order. Reversing the output fails three assertions.

Refs #560. Step 1 of 7.
The transcript pane decided kept-or-removed by asking whether a trim NAMED the
placement — `trimAppliesToClip`, matching a clip id, else an asset id. A
voiceover placement carries an audio fragment id and an audio asset; every trim
carries a video clip. They never matched, so the voiceover lane read every word
as kept, including words whose moment had been cut out of the film.

`buildClipSection` now takes the removed set from `removedRawSpans` instead of
the trim rows, and tags each word by the RAW moment its centre occupies. Centre,
not overlap — mirroring the rule the identity filter used, so the recording
lane's answers do not shift. Both lanes read one set, so a cut on either greys
the other, and the existing shared-media guarantees are unchanged: the same
per-clip walk decides both.

`ClipWord.trimId` and `TrimRun.trimId` become `trimIds: string[]`. Two
consequences worth naming:

  - Overlapping trims merge into one hole carrying both ids, where they used to
    split into one run per attributed trim. Restoring still takes one click per
    trim, as before — step 4 replaces the single-id op with one that drops the
    whole pill.
  - A gap between clips is removed with an EMPTY set. It is missing from the
    film, so its words are struck through; nothing took it, so there is no bin
    icon to click. The affordance is keyed on the set being non-empty.

`findCueWordId` takes a raw second instead of a clip id plus a source second. A
clip id is something only the recording lane has, so the voiceover lane never
highlighted anything at all. Raw time also settles the case the clip id was
introduced for — two clips over one media have identical source ranges but
different raw extents — and `locateVirtualPosition` drops out of the pane.

A LOOPING voiceover now contributes no placement. `anchorAudioTrackFragments`
deliberately does not advance `offsetMs` across a looping track's fragments, so
their words map to raw moments the words do not occupy: the lane would read
kept-or-removed on false evidence and, after step 4, author a cut in the wrong
place.

Forcing `removalAt` to answer null fails 8 assertions across the two aggregator
suites, so the fixtures are exercising the rule rather than agreeing with it by
construction.

Refs #560. Step 2 of 7.
…tail

Step 2 made the transcript pane strike through a voiceover word whose moment the
film had lost. The mix went on playing it anyway — shifted earlier, because an
audio track is laid on the finished programme as one contiguous block — so the
red was a lie in both the preview and the export.

A voiceover is now SLICED by the cuts: `subtractRemoved` over its raw span gives
one mix entry per surviving piece, each reading the source seconds it actually
covers, so the two seconds a cut took are never heard. Fades stay on the TAKE's
outer edges rather than reappearing at every piece.

Music deliberately keeps the old path, byte for byte. A bed plays through a cut
and ends early — slicing it at every edit is a musical regression the current
code avoids on purpose (see the comment it already carries), and a bed has no
words whose redness has to be true. This kind-dependence is the one place the
design departs from a single shared projection, and it is the reason the
departure is worth it.

A LOOPING voiceover also keeps the old path. Step 6 refuses that combination
outright, and inventing semantics for something about to be banned would be the
worse answer.

`resolveVoiceoverPlayback` is extracted next to `resolveTimelineAudioPlayback`
rather than left inline in the rAF, because a decision that has to agree with the
export is a decision worth testing. It asks the question in RAW seconds, which is
both simpler and exact: no projection to invert, and the same question
`removedRawSpans` answers for the words themselves, so the two cannot drift.

Preview and export are held to each other by walking the take frame by frame and
collecting the contiguous runs of source time the preview would play, then
asserting they are the entries the scene description emits, piece for piece.
Letting the take play through the cut fails two of those assertions.

Every existing audio fixture is `kind: "music"`, which is why none of them moved.

Refs #560. Step 3 of 7.
Backspace in the transcript pane handed the write site the asset and clip the
words belonged TO. On the voiceover lane that is an audio asset and an audio
fragment, so the row it wrote matched no clip: `resolvePlaybackSegments` removed
nothing, and the word turned red while the film, the preview and the export were
all unchanged. The lie step 3 made audible is now impossible to write.

`TrimTarget` is gone. Both cut gestures emit a RAW span, and
`ventilateTimelineSpanToTrims` resolves the clips actually under it at the write
site — the same primitive a zoom straddling a boundary uses, so one gesture
becomes several rows and stays one pill.

The span is CLAMPED to the placement's own raw extent before it leaves the pane.
`wordsInRange` admits a word by overlap and consecutive fragments have touching
source windows, so a word straddling an edge would otherwise reach past its
placement — and ventilation walks every clip a span touches, so the overspill
would cut the head of a neighbouring clip the user never pointed at.

A span over no film REFUSES, with a reason. Falling back to the nearest clip —
which is what `trimToTimelineSpan` does for an un-anchored row — would remove
something nobody asked to remove.

Both handlers moved off `applyTimelineOp` onto `enqueueTimelineWrite`, and read
the document INSIDE the chain. `tl.setTrimEntries` reads
`useProjectStore.getState().document` unqueued, so correcting a word and
immediately cutting the next one would let the word edit overwrite the cut —
the exact failure the chain exists to prevent. They are declared in the write
audit with the trigger they carry. `applyTimelineOp` has no callers left here;
the `add_trim_range` / `remove_trim_range` ops stay for the agent, which
addresses clips rather than moments.

Restoring drops every row of the pill through `dropTrimPillsByIds`, instead of
the one id a run happened to carry first.

The insert gesture is refused on the voiceover lane, with its reason shown:
`insertRangeSchema` has no `clipId` and the pause an added word buys is a held
CLIP frame, so a voiceover placement has nothing to hold. Refusing beats writing
a record nothing can read.

Refs #560. Step 4 of 7.
Which lane the transcript is read from was React state in the pane. Captions
could not follow it from there: `buildSceneDescription` takes the document as its
only input, and one of its callers is the headless CLI exporter. A lane living in
a component would have captioned the preview from one lane and the exported file
from the other, with nothing anywhere to notice.

It becomes `captionLane` on `CaptionSettings`, stored through the same façade as
`language` — which is the precedent, and stored for the same reason: it decides
the text burnt into the file. `legacyEditorSchema` is passthrough, so this is
additive: no schema bump, no migration. It is read through the enum guard, so a
hand-edited blob cannot inject a lane the placements would not recognise.

`resolveCaptionLane` carries the fallback and lives in the PURE layer. That
fallback was React-local, and the export path never runs React: a project that
stored "voiceover" after its last take was deleted would have exported zero
captions while the pane calmly showed the recording's.

Two things deliberately did NOT move:

  - `rulerInserts` stays CLIPS-derived on both lanes. A pause is a held clip
    frame — it lengthens the ruler under everything, including a take laid over
    it. Feeding it placements would measure the pause against the take and land
    every voiceover cue early. There is a test that only passes because the
    inserts still resolve through the recording.
  - No removed-word filter in the cue stream. A cue inside a cut maps to frames
    playback never emits, so it is invisible already; dropping the words would
    re-flow every line boundary on any project with a trim — a visible change to
    output, bought for nothing.

`transcriptRelevantAssetIds` widens to the UNION of both lanes: "can this project
be transcribed" is not a per-lane question, and narrowing it would report "no
transcript" on a project whose other lane is full of words.

Also fixed here: the lane switch has been rendering UNSTYLED since it landed. The
script that added its CSS threw on an unrelated edit before reaching the
stylesheet, and every assertion I had was on roles and text, so nothing saw it.
The switch now carries its styles and a line saying out loud that the choice
decides the exported captions — a user must never be surprised by which lane
their subtitles came from.

Refs #560. Step 5 of 7.
"The voiceover" has to name ONE thing for the transcript tab's lane switch to
mean anything. It did not: `packAudioTrackRows` packs by overlap regardless of
kind, and seven hand-rolled `[...others, ...fragments]` splices could each put a
second take on top of the first — two takes recorded from the same playhead
landed exactly there.

`placeAudioTrackInDocument` is the one door now. The mode is the gesture:
"resize" stops the dragged EDGE at the neighbour, "move" keeps the DURATION and
parks the pill against the wall, "create" queues behind what is already there. A
take must never be silently cropped because it was dragged somewhere crowded —
the user asked to move it, not to shorten it.

Deliberately NOT `regionIdentityKey`: `assetId` is in `NON_IDENTITY_FIELDS`, so
two different voiceover files with matching payload hash the same and would
MERGE — splicing two takes into one pill. There is a test named after it.

Only same-kind pills clamp. A voiceover over a music bed is the normal case.

Rows are per kind, with the packer UNCHANGED inside each kind: that keeps a
document written before this rule legible instead of stacking its pills on each
other, and a kind with no tracks takes no row, so the common single-bed project
stays exactly as tall as it was.

Loop is refused on a voiceover. `anchorAudioTrackFragments` does not advance
`offsetMs` across a looping track's fragments, so its words map to raw moments
they do not occupy — step 2 already drops it from the lane, and step 3 has no
slicing branch for it. Music loops; narration does not. The loop FILL now stops
at the next pill of its own kind rather than the programme end, so a bed filling
the timeline cannot swallow a second bed behind it.

The repair lives inside `mapAllRegionCollections`, not at its four call sites, so
no structural edit can skip it. `reanchorAudioTracks` runs first — the generic
pipeline copies `offsetMs` verbatim into every fragment, which corrupts a split
take's offsets, a live bug unrelated to lanes that this walk was already causing
— then `separateAudioLanes` pushes any same-kind pill whose head landed inside
its predecessor forward. Deterministic, order-preserving, idempotent, and it
cannot lose audio: a pill pushed past the programme end still plays, because
removal is defined by trims and gaps only.

Repair, never refusal. A schema refine here would turn an ordinary clip drag into
a thrown save and make every existing document with overlapping same-kind pills
unloadable.

Refs #560. Step 6 of 7.
Commit `b9e0f1ff` let the transcript pane author a cut from the voiceover lane
while still anchoring it on whatever the words belonged to — an audio asset and
an audio fragment. `resolvePlaybackSegments` matches no clip for such a row, so
it removed nothing from the film, the preview or the export. All it did was
strike the word through.

Those rows are now dropped once, at load, in `migrateRawDocumentToCurrent` — the
single funnel every read path crosses. Now that both lanes read one removed set,
leaving them behind would keep striking words through for a cut that never
happened.

The test is exact and needs no clip lookup: an audio asset is never a clip's
`assetId`, because audio is filtered out of the lists that make clips. So a trim
naming one can only have come from that build. An un-anchored pre-v7 trim names a
VIDEO asset and is untouched; so is a trim whose clip was deleted, which
in-session undo can still bring back.

No `schemaVersion` bump: nothing about the format changed, and no output moves —
these rows were already inert. What changes is that words the user "deleted" on
that build come back as kept. That is the correction, and it belongs in the
release note.

`audioTrackPills` goes with it: exported, commented, and never called.

Refs #560. Step 7 of 7.
… one

An added word buys itself time. On the RECORDING lane that time is film: the clip
holds a frame and the ruler grows. On the VOICEOVER lane it is silence inside the
take — the picture is not touched at all, and the narration that follows lands
later against the same image. The previous plan had a voiceover insert holding
the picture on screen; that was wrong and is gone.

`resolveInsertPlacement` reads the lane from the ASSET's kind, which is already
the discriminator the transcript tab uses. Nothing stores a container id, and
that is deliberate: every candidate is ephemeral. A voiceover fragment id is
re-minted by `reanchorAudioTracks` on the first clip drag, and a clip id does not
survive a split — which in this repo is `duplicateClip` plus two
`setClipSourceRange` calls that move `atSec` out of the half that was named.

The two lanes come back in DIFFERENT shapes on purpose. A recording insert can be
given its raw moment immediately, through the clip that plays that source second.
A voiceover insert cannot: its ruler position depends on the insertions before it
inside the same take AND on the cuts under it, and only the take's own walk
resolves that. Handing back an unprojected result is what stops a caller
inventing a projection that would then disagree with the walk.

`insertRangeSchema` is untouched. The `media` enum the earlier plan proposed is
dead schema: nothing would read it, the lane is derivable, and on a voiceover row
it would store a fact about itself that is false — the species the comment above
that schema warns about. No `schemaVersion` bump, no migration.

What did need fixing is user-visible: the writer hard-coded "Held frame for the
added word", which is a lie on a take. It is lane-dependent now. The keying stays
lane-agnostic — one row per word per asset — so `insertRangesMatchWords` is
unchanged and still holds.

A voiceover insert is inert on the film today by ACCIDENT: `rulerInserts` and
`holdAt` both match on `clip.assetId === insert.assetId`, and an audio asset is
never a clip's. That accident is the only reason writing one is currently
harmless, so it is now a rule with a test — each paired with a positive control
so the assertion cannot pass by testing nothing.

Refs #560. Step 1 of 7.
…g it to a word

A take is subject to two opposite forces and they must not be two passes. A cut
under it takes time away — step 3 already slices the mix by `removedRawSpans`. An
insertion inside it adds time: the voice stops and resumes on the same word.

Resolved in two passes, an insertion's raw moment would be computed WITHOUT the
holds before it. Two insertions in one take is enough to show it: the second would
map to a moment inside the first one's hold. `takeProgramme` walks sequentially
with two cursors, and there is a test named after exactly that.

Two things the walk deliberately does NOT do, both settled by the maintainer
after the plan was written:

  - It does not react to an insertion in the RECORDING lane. A word added to the
    film freezes the picture; the take has its own audio and keeps talking,
    finishing that much earlier against a picture that has slid. A take is as long
    as the audio it holds, and nothing underneath changes that. The earlier plan
    had a film hold parking the voice too — that was mine, not asked for, and it
    is gone along with the `filmHolds` argument it needed.
  - It does not lengthen the programme. The clips decide the length. A take
    insertion pushes the take's later content later inside the SAME timeline, and
    whatever that pushes past the last frame is lost at export. That is visible in
    the tests: a ten-second take with a one-second pause consumes nine seconds of
    its file.

It runs on the PILL, never on a stored fragment. The document stores one fragment
per clip a take covers; growing one leaves its successor's head where it was, and
the mixer sums with `+=` at an absolute offset — a fragment-wise walk ships a take
playing on top of itself.

`rawSpanForOutDuration` is the inverse of `outputDurationOfRawSpan`: a voice plays
at 1x in the mix, so a pause is D seconds of the take's own clock, which under a
2x region is two raw seconds and not one.

The loop carries a bounded-pass guard rather than an argument that it terminates.
Mutating the boundary arithmetic did not fail an assertion — it span forever,
which in a renderer is the worst failure there is. The first guard I wrote for it
reset its own counter every pass and caught nothing; counting passes against the
boundary count does, and cannot false-positive on a zero-length insertion, which
makes real progress without moving the cursor.

Refs #560. Step 2 of 7.
…ng early

`projectRawTimelineSecToPlayback` builds the programme clock by accumulating raw
lengths, so it could only ever compress. A pause occupies ZERO raw seconds and D
OUTPUT seconds — the one thing a flat kept-interval list cannot express, which is
why it was left out. The consequence has been shipping: every audio track after a
recording pause lands D seconds early, in the preview and in the export.

The walk now interleaves the pauses with the kept spans rather than adding them
afterwards, because where a pause sits inside a span decides which side of it the
playhead falls on. A pause whose moment a trim removed is in no kept span and is
never reached — the same rule `resolvePlaybackSegments` already follows.

Landing exactly on a pause's moment is the BEGINNING of the hold, matching
`expandRawSec`'s strict edge: a track whose head sits there starts with the pause,
not after it.

`filmInserts` is REQUIRED, and all eight non-test call sites were migrated in this
commit. Optional would have left the bug alive at every site nobody had touched
yet, which is the exact failure the argument exists to fix — and the kind that
resurfaces months later as "the music starts a beat early". The type checker
found all eight; that is what paying for it buys.

Under a speed region the film either side compresses and the pause does not: a
voice plays at 1x, so the second it bought is still a second.

Zeroing the accumulation fails three of the new assertions.

Refs #560. Step 3 of 7.
…nothing

A word added to the recording lengthens the ruler and freezes the preview, and
produced no frames at all in the exported file. `walk_composited_timeline` skips
every clip whose source window is empty (`timeline_walk.rs:250`), and a held
segment is exactly that shape — it has no source to decode and exists only for
the frames it holds.

`hold_sec` threads through five mirrors, because one missed site would drop it
silently on one platform with no compile error: the TS contract, the napi input
object and BOTH of its mapping sites, `ClipSource` on Windows, macOS and Linux,
and `SceneClip` as `#[serde(default)]` — mirroring `has_audio` three lines above
it, so a document written before the field loads at 0.0.

The held segment stays its OWN clip. Folding it onto its predecessor was the
earlier plan and it was wrong: scene regions are keyed by clip INDEX, so removing
an entry points every region after it at the wrong clip, and `resolveVisibleClips`
has four consumers rather than the two that would have been folded.

The held frames are emitted AFTER the speed segments and deliberately outside
them. Inside, `stretch_pcm_to_length` would WSOLA the clip's real voice across the
freeze — audible. Outside, the audio slot is longer than the decoded PCM and
`min(source.len())` leaves zeros, so the silence costs nothing: no silent file to
decode, no extra mix entry.

`ceil`, not `round`: rounding loses up to half a frame, taken from the end of the
narration the pause exists to make room for.

The last composed pair is hoisted out of the segment loop, because `'clip_frames`
can break and a hold-only clip never enters it — there would be no frame in scope
to hold. A clip that yields no frame at all warns and emits none, rather than
composing nothing.

`resolveVisibleClips` returns `PlaybackSegment[]` now: widening it to `AxcutClip[]`
threw `heldSec` away, which is why the pause could never reach the compositor in
the first place.

Verified here, not left to CI: `cargo check --all-targets` clean on the compositor
and the napi bridge, and all 181 Rust unit tests pass — the test binary needs the
bundled ffmpeg DLLs on PATH, which is what the earlier "cannot build in this
worktree" note was really about.

Refs #560. Step 4 of 7.
The export sliced a take with `subtractRemoved` and the transcript placed it one
per stored FRAGMENT. Both were right while a take could only lose time and wrong
the moment it can gain some: a fragment's source window knows nothing about a
pause before it, so every word after an insertion would be struck through — and
highlighted — at a moment it is not heard.

The export now walks the PILL, once, from its head fragment. The document keeps
one fragment per clip a take covers, and walking them separately would emit
overlapping entries into a mixer that sums with `+=` at an absolute offset: the
file would contain the take playing on top of itself.

`voiceoverPlacements` emits one placement per PLAY PIECE. A ventilated take with
no cuts folds back into ONE placement, which is what it always should have been —
the fragments exist because the take spans a cut in the film, not because the
narration is in two pieces. With cuts it splits at the cuts; with an insertion the
piece after it carries the ruler moment the words actually occupy.

That also settles the fourth copy of the affine map. `findCueWordId` carries its
own inverse, and the plan expected it to need an insertion term. It does not —
provided every placement is affine, which walking by play pieces is exactly what
buys. Asserted rather than assumed: the cue follows a word pushed later by a
pause, and goes quiet while the voice is parked, because no word is being said
there.

The caption deriver and the transcript pane are fed the same two arguments, so
the burnt subtitles and the strikethrough cannot disagree with the mix.

One test changed its mind rather than its expectation: it asserted a ventilated
take keeps one placement per fragment, on the reasoning that collapsing would
re-read the file from its head at every cut. The walk recomputes the source
advance, so collapsing is safe now — and necessary.

Refs #560. Step 5 of 7.
The rAF drove each stored FRAGMENT of a take independently. That was harmless
while a take could only lose time; with an insertion it puts the take on top of
itself, exactly as it would in the export — the document keeps one fragment per
clip a take covers, and each would play its own slice from its own head.

Every fragment but the group's head is now paused, and the head is driven by one
walk over the PILL, memoised per take and recomputed only when the cuts or the
insertions move.

Inside a pause the walk returns the PARKED source second and `shouldPlay: false`
— one value for the whole hold. A drifting target would re-seek a paused element
every frame, and resuming from the post-insert source would restart the narration
on the wrong word. Asserted both ways: the target is a single value across the
pause, and the resume lands on the second it stopped on.

Fades measure against the SOURCE the walk consumes, never the take's ruler
extent. An insertion grows the extent without adding a second of file, so a
fade-out measured on it would start early here and nowhere else — a preview that
disagrees with `resolve_fade_samples` and nothing to say why.

`resolveVoiceoverPlayback` is deleted rather than adapted: `takePlaybackAt` is
the same question asked of the walk, and keeping both would be two answers to it.

The picture is untouched. Nothing in the film holds for a voiceover insertion —
the take stops, the film runs on underneath.

One test had to change its detector, not its expectation: it broke runs on a jump
in source time, and across a pause the source is deliberately CONTINUOUS, because
the voice resumes on the word it stopped on. It breaks on silence now.

Refs #560. Step 6 of 7.
The gesture is lifted on the voice-over lane. Its stated reason — "a voiceover
placement has no clip to hold" — was the model the maintainer corrected: nothing
is held there, the voice simply stops and the film runs on underneath. The
`import.meta.env.DEV` gate stays and gates the GESTURE only; the record, the
readers and the rendering are live in both builds, so a project made in dev opens
and plays in production.

The mark finally has a width. `.tlClipInsert::before` was pinned at `width: 3px`
while the button carried the pause's real width inline, so a word that bought two
seconds and one that bought nothing drew the same tick — and the wide case was a
multi-second INVISIBLE column that swallowed clip drags. It fills its button now,
with a floor so a word that borrowed existing silence stays clickable.

Two defects lived in one ternary. A word WITH a pause was placed on the expanded
ruler and one WITHOUT at a fraction of the clip's SOURCE span — two clocks, and
the box is drawn in only one of them. Both edges were inclusive, so a word whose
pause sits on a split boundary painted a mark in BOTH halves, which is routine
because a pause sits at the END of the word it follows.

`insertedWordMarks` is extracted so that is testable at all: the suite has no
render harness for this component, and the alternative was building one for a
single assertion. Half-open at every clip's far edge except the last, or the final
word of a project disappears. Removing the boundary rule fails an assertion.

Audio pills move from `pctOf` to `pctAt`. The clip boxes are drawn on the EXPANDED
ruler and the pills were drawn on the stored one, so any pause in the film slid
the two lanes apart — a bug that has been there since the ruler learned to expand,
visible the moment anyone adds a word. The take keeps its own length; only its
head follows the ruler.

Refs #560. Step 7 of 7.
A take that holds somewhere is drawn in pieces: one waveform per stretch it
plays, and a hatched amber column where the voice stops. Inside ONE outline, on
purpose — the take is still one take, one draggable and slippable object, and the
eye should read "the voice stops here", not "two takes".

Opposite polarity to the clip lane's band. That one means the picture freezes
here; this one means the voice stops here and the film runs on underneath, which
is the whole difference between the two insertion lanes.

Positioned from the same walk the preview and the export read, so a notch cannot
appear where the voice does not actually stop. A take with no insertion — and
every music bed, and every looping take — keeps the single unbroken waveform it
has always had, so the common pill is untouched.

The notch takes no hit target of its own: clicking still selects the take, and
the word is deleted from the transcript, which is where it was created.

Refs #560.
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.

Feature Request: Add Voiceover / External BGM / Sound Support

4 participants