feat(media): expose and rehydrate speed state from MediaEditor - #343
Conversation
Speed markers and the default playback speed lived only in component state, so hosts could not persist them and anything downstream (like a server-side export render) silently ignored speed edits. Adds initialSpeedMarkers/initialDefaultSpeed options to useTranscriptEdits and MediaEditor, plus an onSpeedStateChange callback (fires on mount with initial values and on every change).
There was a problem hiding this comment.
Pull request overview
Adds a persistence-friendly “speed state” surface area to MediaEditor/useTranscriptEdits so hosts can save/restore speed markers and default playback speed (and downstream consumers can bake speed ramps into export renders). This PR is stacked on #342, so it also contains the drag-selection improvements and their regression tests.
Changes:
useTranscriptEdits: acceptsinitialSpeedMarkers/initialDefaultSpeedto seed speed state.MediaEditor: exposes the same initial props and emitsonSpeedStateChange(speedMarkers, defaultSpeed)on mount and whenever speed state changes.- Tests: adds coverage for speed-state rehydration and drag-selection behavior beyond pane bounds.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/hooks/useTranscriptEdits.ts | Adds options to initialize speed markers/default speed in the headless edit hook. |
| src/components/MediaEditor/MediaEditor.tsx | Plumbs initial speed props through and adds onSpeedStateChange; also includes stacked drag-selection changes. |
| src/components/MediaEditor/MediaEditor.test.tsx | Adds regression tests for speed rehydration callback and drag-selection tracking outside the pane. |
Speed: markers and the default speed now persist in edits.json (PUT merges missing fields so a speed-only save cannot clobber undo history), rehydrate into the editor via the new MediaEditor props, and bake into renders — segments split wherever the effective speed changes, video via setpts, audio via atempo (clamped to atempo's 0.5-2 range, which is the editor's own speed range). Verified: 2s@2x + 3s@1x source renders 4.03s; a marker mid-run splits the segment (3s source -> 2.97s). Captions: the export plan maps every spoken word onto the exported timeline (cut- and speed-aware); cues group to ~64 chars broken on dead air, written as an export.srt sidecar on every export and burned into the video via the subtitles filter when the new dialog checkbox is on. Hosts whose ffmpeg lacks libass render unburned instead of failing (the dev box's ffmpeg has it; some Homebrew builds do not). Requires @mieweb/ui with MediaEditor speed-state props (mieweb/ui#343). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Speed: markers and the default speed now persist in edits.json (PUT merges missing fields so a speed-only save cannot clobber undo history), rehydrate into the editor via the new MediaEditor props, and bake into renders — segments split wherever the effective speed changes, video via setpts, audio via atempo (clamped to atempo's 0.5-2 range, which is the editor's own speed range). Verified: 2s@2x + 3s@1x source renders 4.03s; a marker mid-run splits the segment (3s source -> 2.97s). Captions: the export plan maps every spoken word onto the exported timeline (cut- and speed-aware); cues group to ~64 chars broken on dead air, written as an export.srt sidecar on every export and burned into the video via the subtitles filter when the new dialog checkbox is on. Hosts whose ffmpeg lacks libass render unburned instead of failing (the dev box's ffmpeg has it; some Homebrew builds do not). Requires @mieweb/ui with MediaEditor speed-state props (mieweb/ui#343). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…set speed state Review findings: initialSpeedMarkers/initialDefaultSpeed seeded state without setting hasEdits (inconsistent with toggleSpeedMarker and the flag's documented contract), and the transcript-change reset left speed markers pointing at the old transcript's word indexes.
…ed undo - Right-clicking a speed with an active selection applies it to the whole range: markers inside are cleared, the start is marked, and the prior effective speed is restored just after the end (one undo step) - Every word playing at a non-default speed gets a dotted warning underline, so the affected region reads at a glance instead of only the first word carrying a badge - Speed changes are undoable: a speed snapshot stack rides alongside the word undo stack (synthesized entries for rehydrated stacks leave speed at its saved state), and toggle/remove/default-speed/range setters all push before mutating
…y and drag perf - setSilenceThresholds now remaps speed markers through the rebuild using the same non-silence ordinal keyspace as deletions (markers kept stale indices once the silence layout changed) and drops the speed undo stack alongside the word undo stack so they can't drift. - onSpeedStateChange fires only on real state changes: the callback rides a ref, so inline handlers don't re-notify unchanged state. - Drag auto-scroll skips re-extending when scrollTop is clamped at an edge; the above-first-word case fast-paths before the span scan.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
src/hooks/useTranscriptEdits.ts:933
setSpeedForRangealways adds a marker atstartIndex, even when the requestedspeedis already the effective speed at the start of the range (e.g. applying 1x when the default is 1). This creates redundant{wordIndex, speed: 1}markers that will be persisted/exported even though they don’t change playback.
const kept = speedMarkers.filter(
(m) => m.wordIndex < startIndex || m.wordIndex > endIndex
);
const next = [...kept, { wordIndex: startIndex, speed }];
if (
…nchanged setSpeedForRange pinned a marker at startIndex unconditionally, so applying a speed already in effect (e.g. 1x under a 1x default, or a preceding marker's speed) persisted a no-op marker into saves and exports. Only add the start marker when it actually changes the effective speed there, mirroring the existing guard on the range's restore marker. Adds a regression test.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
src/hooks/useTranscriptEdits.ts:917
setSpeedForRangealways callspushUndo()and setshasEdits=trueeven when applying a speed that is already effectively in place for the whole range and there are no markers to clear (e.g. applying 1x to a fresh transcript). In that no-op case the finalspeedMarkersstays the same, but you still create an undo step and mark the editor dirty, which can trigger unnecessary persistence/export updates.
(startIndex: number, endIndex: number, speed: PlaybackSpeed) => {
if (startIndex > endIndex) return;
pushUndo();
const afterIndex = endIndex + 1;
const speedAfter = getSpeedAtIndex(
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/hooks/useTranscriptEdits.ts:939
setSpeedForRangepushes an undo snapshot and setshasEdits=trueeven when the operation is a true no-op (e.g., applying 1x to a region when the effective speed is already 1x and there are no markers to clear / no restore marker to add). This creates an extra undo step and incorrectly marks the editor as edited.
pushUndo();
const afterIndex = endIndex + 1;
const speedAfter = getSpeedAtIndex(
afterIndex,
speedMarkers,
7a899c6 to
db74282
Compare
Speed: markers and the default speed now persist in edits.json (PUT merges missing fields so a speed-only save cannot clobber undo history), rehydrate into the editor via the new MediaEditor props, and bake into renders — segments split wherever the effective speed changes, video via setpts, audio via atempo (clamped to atempo's 0.5-2 range, which is the editor's own speed range). Verified: 2s@2x + 3s@1x source renders 4.03s; a marker mid-run splits the segment (3s source -> 2.97s). Captions: the export plan maps every spoken word onto the exported timeline (cut- and speed-aware); cues group to ~64 chars broken on dead air, written as an export.srt sidecar on every export and burned into the video via the subtitles filter when the new dialog checkbox is on. Hosts whose ffmpeg lacks libass render unburned instead of failing (the dev box's ffmpeg has it; some Homebrew builds do not). Requires @mieweb/ui with MediaEditor speed-state props (mieweb/ui#343).
Speed: markers and the default speed now persist in edits.json (PUT merges missing fields so a speed-only save cannot clobber undo history), rehydrate into the editor via the new MediaEditor props, and bake into renders — segments split wherever the effective speed changes, video via setpts, audio via atempo (clamped to atempo's 0.5-2 range, which is the editor's own speed range). Verified: 2s@2x + 3s@1x source renders 4.03s; a marker mid-run splits the segment (3s source -> 2.97s). Captions: the export plan maps every spoken word onto the exported timeline (cut- and speed-aware); cues group to ~64 chars broken on dead air, written as an export.srt sidecar on every export and burned into the video via the subtitles filter when the new dialog checkbox is on. Hosts whose ffmpeg lacks libass render unburned instead of failing (the dev box's ffmpeg has it; some Homebrew builds do not). Requires @mieweb/ui with MediaEditor speed-state props (mieweb/ui#343).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/hooks/useTranscriptEdits.ts:479
- Speed rehydration now sets
hasEdits=true(needed), but it also meansonChangecan fire on the first render when only speed state was rehydrated (e.g.initialSpeedMarkersprovided butinitialEditedWordsundefined). That can cause hosts to persist a baselineeditedWords/undoStackpayload on mount even though there was no in-session mutation. Consider treating any provided initial saved state (words/undo/speed) as “initialized from saved” and skipping the firstonChangenotification in that case (not just wheninitialEditedWordsis present).
// Speed markers index into the old transcript's word list; keeping them
// would apply stale rates to arbitrary words of the new transcript
setSpeedMarkers([]);
setDefaultSpeed(1);
setSpeedUndoStack([]);
initializedFromSaved.current = false;
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/components/MediaEditor/MediaEditor.tsx:218
- New Tailwind utility classes were added for the sped-region underline (
decoration-warning,decoration-dotted,decoration-2,underline-offset-4). For Tailwind CSS 3 consumers that don’t scannode_modules, these need to be added tomiewebUISafelist(seesrc/tailwind-preset.ts’s safelist contract) or the underline styling won’t be emitted in consumer builds.
// A dotted warning underline spans every word of a re-timed region, so the
// affected range reads at a glance (the badge only sits on the first word)
if (s.isSped) {
parts.push(
'underline decoration-warning decoration-dotted decoration-2 underline-offset-4'
);
garrity-miepub
left a comment
There was a problem hiding this comment.
Reviewed the hook and editor changes closely. The speed undo stack riding alongside the word undo stack is a clean design, and re-anchoring markers through silence-threshold rebuilds was a great catch. All Copilot findings addressed with regression tests.
Verified locally against latest main: typecheck, lint, format, and 415/415 tests green. Also tested the range-speed flow hands-on in Storybook — drag select, apply 2x, auto-restore marker after the range, single-step undo all work as described.
Approving! 🚀
Stacked on #342 — the first four commits are that PR; this adds five on top. Rebase/merge after #342 lands and the diff shrinks to it.
Speed markers and the default playback speed live only in MediaEditor component state today: hosts can't persist them, so they vanish on reload — and pulseclip's new server-side export render can't bake speed ramps it never receives (the one documented gap in mieweb/pulseclip#30).
useTranscriptEdits:initialSpeedMarkers/initialDefaultSpeedoptions seed the stateMediaEditor: same two props passed through, plusonSpeedStateChange(speedMarkers, defaultSpeed)— fires once on mount with the initial values and on every change, mirroringonEditedWordsRenderConsumer: pulseclip persists these in
edits.jsonalongside the edit list and applies them in the export render (setpts/atempo).Gates: lint ✅ typecheck ✅ format ✅ 400/400 tests ✅