Skip to content

feat(media): expose and rehydrate speed state from MediaEditor - #343

Merged
garrity-miepub merged 9 commits into
mieweb:mainfrom
jlocala1:feat/media-speed-persistence
Aug 4, 2026
Merged

feat(media): expose and rehydrate speed state from MediaEditor#343
garrity-miepub merged 9 commits into
mieweb:mainfrom
jlocala1:feat/media-speed-persistence

Conversation

@jlocala1

@jlocala1 jlocala1 commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

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 / initialDefaultSpeed options seed the state
  • MediaEditor: same two props passed through, plus onSpeedStateChange(speedMarkers, defaultSpeed) — fires once on mount with the initial values and on every change, mirroring onEditedWordsRender
  • Purely additive; existing callers unaffected

Consumer: pulseclip persists these in edits.json alongside the edit list and applies them in the export render (setpts/atempo).

Gates: lint ✅ typecheck ✅ format ✅ 400/400 tests ✅

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).
Copilot AI review requested due to automatic review settings July 27, 2026 18:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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: accepts initialSpeedMarkers / initialDefaultSpeed to seed speed state.
  • MediaEditor: exposes the same initial props and emits onSpeedStateChange(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.

Comment thread src/hooks/useTranscriptEdits.ts
jlocala1 added a commit to jlocala1/pulseclip that referenced this pull request Jul 27, 2026
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>
jlocala1 added a commit to jlocala1/pulseclip that referenced this pull request Jul 27, 2026
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>
jlocala1 added a commit to jlocala1/pulseclip that referenced this pull request Jul 27, 2026
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.
Copilot AI review requested due to automatic review settings July 27, 2026 20:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment thread src/components/MediaEditor/MediaEditor.tsx Outdated
Comment thread src/components/MediaEditor/MediaEditor.tsx Outdated
…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
Copilot AI review requested due to automatic review settings July 27, 2026 21:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread src/hooks/useTranscriptEdits.ts
Comment thread src/components/MediaEditor/MediaEditor.tsx
…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.
Copilot AI review requested due to automatic review settings July 28, 2026 16:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

  • setSpeedForRange always adds a marker at startIndex, even when the requested speed is 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.
Copilot AI review requested due to automatic review settings July 28, 2026 22:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

  • setSpeedForRange always calls pushUndo() and sets hasEdits=true even 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 final speedMarkers stays 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(

@jlocala1
jlocala1 marked this pull request as ready for review August 3, 2026 19:31
Copilot AI review requested due to automatic review settings August 3, 2026 19:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

  • setSpeedForRange pushes an undo snapshot and sets hasEdits=true even 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,

@jlocala1
jlocala1 force-pushed the feat/media-speed-persistence branch from 7a899c6 to db74282 Compare August 3, 2026 19:54
jlocala1 added a commit to jlocala1/pulseclip that referenced this pull request Aug 3, 2026
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).
jlocala1 added a commit to jlocala1/pulseclip that referenced this pull request Aug 3, 2026
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).
jlocala1 added a commit to jlocala1/pulseclip that referenced this pull request Aug 3, 2026
Copilot AI review requested due to automatic review settings August 3, 2026 22:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 means onChange can fire on the first render when only speed state was rehydrated (e.g. initialSpeedMarkers provided but initialEditedWords undefined). That can cause hosts to persist a baseline editedWords/undoStack payload 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 first onChange notification in that case (not just when initialEditedWords is 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;

@garrity-miepub
garrity-miepub self-requested a review August 4, 2026 00:22
Copilot AI review requested due to automatic review settings August 4, 2026 01:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 scan node_modules, these need to be added to miewebUISafelist (see src/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 garrity-miepub left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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! 🚀

@garrity-miepub
garrity-miepub merged commit 5002fdc into mieweb:main Aug 4, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants