Skip to content

feat: raise playback-speed cap to 100x#80

Merged
EtienneLescot merged 5 commits into
getopenscreen:mainfrom
rainyflash:feat/speed-cap-100x
Jul 11, 2026
Merged

feat: raise playback-speed cap to 100x#80
EtienneLescot merged 5 commits into
getopenscreen:mainfrom
rainyflash:feat/speed-cap-100x

Conversation

@rainyflash

@rainyflash rainyflash commented Jul 8, 2026

Copy link
Copy Markdown

Closes #79.

What

Raises the editor's playback-speed cap from 16× to 100×.

The cap existed because Chromium hard-caps HTMLMediaElement.playbackRate at 16× (setting more throws NotSupportedError), and both preview and audio export drove speed straight through a media element. This PR takes the two playbackRate-bound paths off the real-time engine only for segments above 16×; everything at or below 16× keeps its exact current behaviour.

How

Shared segment math (timelineSegments.ts) — computeKeepSegments + splitBySpeed are extracted from streamingDecoder's private methods into one module now used by both the video decoder and the new offline audio path, so the two can't compute different timelines (behaviour-preserving refactor; covered by new unit tests).

Preview (videoEventHandlers.ts, VideoPlayback.tsx) — for a segment faster than 16×, the muted element stays "playing" at the native ceiling while the rAF loop advances a virtual clock at the true speed and forward-seeks the element to it each frame ("seek-stepping" — a fast frame-step, like native editors at extreme rates). The ≤16× native path is unchanged. Supplemental audio is paused and the webcam rate is clamped in those regions.

Video export — no change needed. streamingDecoder already retimes frames offline (sourceTime = start + (frameIndex/fps)·speed) with no speed ceiling; 100× worked already and was only fenced off by the UI.

Audio export (audioEncoder.ts, audioTimeStretch.ts) — the real-time <audio> + MediaRecorder capture can't exceed 16×, so when any segment does, the whole speed timeline is rendered offline with a streaming WSOLA (pitch-preserving) time-stretch instead. Timelines whose fastest segment is ≤16× still use the existing real-time path unchanged. Each segment's output is clamped to round(inputSamples/speed) so audio stays locked to the independently-retimed video, and the stretcher streams (bounded memory) so a multi-hour region isn't held in RAM.

UI — the custom-speed input's cap is raised (16→100); the preset buttons are untouched. A small hint appears when a region is set above 16× explaining that preview frame-steps and is muted there while the export is unaffected (added to all 13 locales). The existing "too fast" message is interpolated so it reflects the new cap ({{max}}).

Tradeoffs / notes

  • Preview above 16× is a best-effort frame-step (seeking-per-frame), not smooth playback — expected at those rates, and the export is always frame-accurate regardless.
  • Preview audio is muted above 16×; the exported audio is full pitch-preserved WSOLA.
  • WSOLA quality is good in the 16–30× range and degrades gracefully toward 100× (audio there carries little information anyway); 100× is the industry ceiling for meaningful pitch-preserved audio (ffmpeg atempo, Premiere audio remap, CapCut all cap there).

Tests

New unit tests: WSOLA pitch-preservation + length scaling + passthrough + amplitude (audioTimeStretch.test.ts), shared segment math (timelineSegments.test.ts), updated cap boundary tests (customPlaybackSpeed.test.ts), 100× fast-path guard (videoExporter.test.ts). Full suite green; typecheck and Biome clean.

Testing / platform

Verified end-to-end on macOS (Apple Silicon) and Windows — preview frame-stepping above 16× and the offline pitch-preserved audio export both check out on each. (An early revision froze the >16× preview on Windows because it seeked the element every animation frame and the slower decoder stayed perpetually mid-seek; the seek is now throttled to seeked completion, so it steps frames on both.) I haven't tested Linux.

Summary by CodeRabbit

  • New Features
    • Video speed controls now support up to 100x. Above the native playback ceiling, preview switches to frame-by-frame stepping and displays a hint.
  • Bug Fixes
    • Improved high-speed playback stability by preventing native playback-rate failures and refining preview/audio syncing behavior.
    • Export now reliably processes extreme speeds using pitch-preserving offline audio stretching when needed.
  • Tests
    • Expanded coverage for custom speed parsing, WSOLA stretching, and timeline segmentation.
  • Documentation
    • Updated localized speed-limit messaging (templated max values) and added the new preview hint across languages.

Chromium hard-caps HTMLMediaElement.playbackRate at 16, so preview and the
real-time audio-export capture cannot exceed 16x. This takes both paths off
the media element only above 16x, leaving the <=16x behaviour untouched:

- Preview: >16x segments frame-step (a virtual clock forward-seeks the muted
  element each frame); <=16x plays natively exactly as before.
- Audio export: a timeline with any >16x segment renders offline with a
  streaming WSOLA pitch-preserving time-stretch; <=16x keeps the proven
  real-time capture path.
- Video export already retimed frames offline with no ceiling - unchanged.
- Timeline-segment math is extracted to timelineSegments.ts so the video
  decoder and the offline audio path derive identical segments (A/V sync).
- UI: the custom-speed input now accepts up to 100x (presets unchanged), with
  a hint that >16x previews as muted frame-stepping while export is unaffected.

Closes getopenscreen#79

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ab3a98d4-4d4c-4703-add3-b06ca44e5098

📥 Commits

Reviewing files that changed from the base of the PR and between b380556 and f6c503a.

📒 Files selected for processing (1)
  • src/lib/exporter/audioEncoder.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lib/exporter/audioEncoder.ts

📝 Walkthrough

Walkthrough

This PR raises the playback-speed cap to 100x, adds native-cap preview stepping above 16x, and routes fast-speed export audio through an offline WSOLA-based path. Shared timeline-segmentation helpers are introduced and reused across preview and export code.

Changes

Playback speed cap increase

Layer / File(s) Summary
Speed constants and validation
src/components/video-editor/types.ts, src/components/video-editor/customPlaybackSpeed.test.ts
MAX_PLAYBACK_SPEED is raised to 100, MAX_NATIVE_PLAYBACK_RATE is added as 16, and custom-speed validation tests are updated for the new boundaries.
Settings panel UI updates
src/components/video-editor/SettingsPanel.tsx, src/i18n/locales/*/settings.json
Imports the new constants, parameterizes the max-speed toast, shows a preview hint when the selected speed exceeds the native cap, and updates localized speed strings for the new limits.
Preview seek-stepping above native rate
src/components/video-editor/VideoPlayback.tsx, src/components/video-editor/videoPlayback/videoEventHandlers.ts
Adds seek-stepping refs and state, clamps media playback rates to the native cap, and rewrites preview event handling for stepped playback above 16x.
Timeline segmentation utilities
src/lib/exporter/timelineSegments.ts, src/lib/exporter/timelineSegments.test.ts, src/lib/exporter/streamingDecoder.ts
Adds shared segment types and helpers, tests trim/speed splitting behavior, and refactors the streaming decoder to use the shared helpers.
Offline WSOLA time-stretcher
src/lib/exporter/audioTimeStretch.ts, src/lib/exporter/audioTimeStretch.test.ts, src/lib/exporter/planarChunkQueue.ts, src/lib/exporter/planarChunkQueue.test.ts
Adds the WSOLA stretcher implementation plus buffering helpers and tests for duration, pitch, overlap-add, and planar chunk queue behavior.
Offline audio export integration
src/lib/exporter/audioEncoder.ts, src/lib/exporter/videoExporter.ts, src/lib/exporter/videoExporter.test.ts
Extends audio export with a frame-rate argument, adds the offline rendering branch for fast segments, passes frame rate through the exporter, and updates the fast-path test.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RAF as AnimationFrame
  participant Handlers as videoEventHandlers
  participant Video as HTMLVideoElement

  RAF->>Handlers: updateTime tick
  Handlers->>Handlers: compute activeSpeed
  alt speed > MAX_NATIVE_PLAYBACK_RATE
    Handlers->>Handlers: advance virtual clock
    Handlers->>Video: seek to virtual time (muted)
    Video-->>Handlers: seeked event
    Handlers->>Handlers: clear step-seek in-flight
  else speed <= MAX_NATIVE_PLAYBACK_RATE
    Handlers->>Video: set native playbackRate
    Handlers->>Video: resume native play
  end
Loading
sequenceDiagram
  participant Exporter as VideoExporter
  participant Processor as AudioProcessor
  participant Timeline as timelineSegments
  participant Wsola as WsolaTimeStretcher
  participant Muxer as VideoMuxer

  Exporter->>Processor: process(..., targetFrameRate)
  Processor->>Timeline: buildSpeedSegments(trim, speedRegions)
  Processor->>Timeline: maxTimelineSpeed(segments)
  alt maxSpeed > MAX_NATIVE_PLAYBACK_RATE
    Processor->>Wsola: stretch per segment via push/flush
    Wsola-->>Processor: stretched PCM
    Processor->>Muxer: mux encoded offline audio
  else maxSpeed <= MAX_NATIVE_PLAYBACK_RATE
    Processor->>Muxer: mux real-time pitch-preserved audio
  end
Loading

Suggested reviewers: EtienneLescot

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: raising the playback-speed cap to 100×.
Description check ✅ Passed The description covers the change, issue link, implementation, and tests, though several template checklist sections are omitted.
Linked Issues check ✅ Passed The changes match #79: 100× cap, frame-stepping preview above 16×, offline audio export, and updated UI limits and hint text.
Out of Scope Changes check ✅ Passed The added modules and tests all support the speed-cap feature; no unrelated or clearly out-of-scope changes stand out.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

Inline comments:
In `@src/lib/exporter/timelineSegments.ts`:
- Around line 24-50: The computeKeepSegments function can move cursor backward
when trimRegions overlap or nest because it assigns cursor directly from each
trim’s end, which can cause already-trimmed ranges to be emitted again. Update
the loop in computeKeepSegments to keep cursor monotonic by advancing it with
the furthest trim end seen so far, while preserving the existing
sorted-by-startMs flow and the final tail segment handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: dc8cfcf5-66d8-4cee-af1a-30cdd01afdee

📥 Commits

Reviewing files that changed from the base of the PR and between b67811f and 6aad00b.

📒 Files selected for processing (26)
  • src/components/video-editor/SettingsPanel.tsx
  • src/components/video-editor/VideoPlayback.tsx
  • src/components/video-editor/customPlaybackSpeed.test.ts
  • src/components/video-editor/types.ts
  • src/components/video-editor/videoPlayback/videoEventHandlers.ts
  • src/i18n/locales/ar/settings.json
  • src/i18n/locales/en/settings.json
  • src/i18n/locales/es/settings.json
  • src/i18n/locales/fr/settings.json
  • src/i18n/locales/it/settings.json
  • src/i18n/locales/ja-JP/settings.json
  • src/i18n/locales/ko-KR/settings.json
  • src/i18n/locales/pt-BR/settings.json
  • src/i18n/locales/ru/settings.json
  • src/i18n/locales/tr/settings.json
  • src/i18n/locales/vi/settings.json
  • src/i18n/locales/zh-CN/settings.json
  • src/i18n/locales/zh-TW/settings.json
  • src/lib/exporter/audioEncoder.ts
  • src/lib/exporter/audioTimeStretch.test.ts
  • src/lib/exporter/audioTimeStretch.ts
  • src/lib/exporter/streamingDecoder.ts
  • src/lib/exporter/timelineSegments.test.ts
  • src/lib/exporter/timelineSegments.ts
  • src/lib/exporter/videoExporter.test.ts
  • src/lib/exporter/videoExporter.ts

Comment thread src/lib/exporter/timelineSegments.ts
rainyflash and others added 2 commits July 8, 2026 00:10
A trim nested inside another (the list is sorted only by start) could move the
keep-cursor backward and re-emit source an earlier trim already removed. Clamp
with Math.max so the shared video + offline-audio segment builder stays correct
for overlapping regions or legacy saved data. No change for non-overlapping trims.

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

@EtienneLescot EtienneLescot 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.

A few issues from a careful read. The main one is a webcam-preview freeze on Windows that this PR's throttle for the main preview doesn't cover. The remaining items are smaller.

Blocking

  • Webcam preview still re-seeks every frame in >16× regions (VideoPlayback.tsx ~1866) — see inline comment. Reproduces the failure mode the main preview was just fixed for. The seekSteppingRef/stepSeekInFlight machinery added for the main video needs an equivalent here, or at minimum the resync needs to be skipped while the main video is in seek-stepping mode.

Worth fixing before merge

  • appendOutput is O(N²) in output samples for long regions (audioEncoder.ts:717). The offline path is selected whenever any segment exceeds 16×, so a project with one >16× spike and a long 1× run will spend quadratic time copying the accumulator. A circular buffer / amortized slab fixes it.
  • First decoded AudioData.timestamp is non-zero in practice (audioEncoder.ts:827). The frameStartSample seed gives a source-time origin offset from 0, which can drop a few hundred input samples at the start of the timeline. Worth knowing about; the test sources don't exercise it.
  • Misleading test comment (videoExporter.test.ts:55) — see inline comment.

Minor

  • Silence pad in finalizeSegment (audioEncoder.ts:761) deserves a one-line comment so future readers don't try to "fix" the underrun branch — the silence pad is what keeps A/V lock when WSOLA under-fills on a short high-speed segment.

Overall the seek-stepping throttle for the main preview is a thoughtful fix, but the same fix has to land on the webcam path before this is safe on Windows. The O(N²) appendOutput is the other real concern.

Comment thread src/components/video-editor/VideoPlayback.tsx
Comment thread src/lib/exporter/audioEncoder.ts Outdated
Comment thread src/lib/exporter/audioEncoder.ts
Comment thread src/lib/exporter/audioEncoder.ts
Comment thread src/lib/exporter/videoExporter.test.ts Outdated
- Skip the webcam per-frame currentTime resync while the main video is in >16x
  seek-stepping, so its decoder isn't driven perpetually mid-seek — the same
  Windows freeze the main-preview throttle fixed, on the webcam path.
- Replace the O(N^2) grow-and-copy output accumulator in the offline audio path
  with a front-drained chunk queue (PlanarChunkQueue, O(1) amortized) + unit tests.
- Keep offline audio on raw source timestamps (the same origin the video decoder
  uses) so codec preroll is dropped and A/V stays locked; add a clarifying comment
  rather than re-anchoring, which would desync audio from the video path.
- Clarify the silence-pad and fast-path-block test comments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rainyflash

Copy link
Copy Markdown
Author

Thanks for the careful read — all addressed in b380556.

Webcam preview freeze (blocking). Fixed. The webcam resync is now skipped while the main video is in seek-stepping (!seekSteppingRef.current); the muted webcam just plays best-effort at the 16×-clamped rate during >16× regions, exactly as you suggested. Good catch — same failure mode as the main preview, and I'd only fixed half of it.

appendOutput O(N²). Fixed. Pulled the accumulator out into a small PlanarChunkQueue (src/lib/exporter/planarChunkQueue.ts): append stores chunk references (O(1)); the encoder slices are coalesced from the front (O(take)), so a long 1× run in the offline path is O(total) instead of O(N²). Added unit tests covering FIFO ordering across chunk boundaries, partial heads, planar multi-channel layout, and a stress case.

First AudioData.timestamp non-zero. I tried the re-anchor (seed origin from the first frame) but backed it out — on a closer look it breaks A/V sync rather than helping. The video path (streamingDecoder) uses raw source timestamps as the timeline origin with no shift, so re-anchoring audio to its own first-frame timestamp puts the two tracks in different coordinate systems: an imported AAC clip with, say, −44 ms decoder priming would end up with the whole audio track offset ~44 ms from video. Keeping raw timestamps is the correct behavior here — negative-timestamp preroll is dropped (as it should be), and any positive leading offset matches how the video path treats it, so they stay locked. Added a comment to that effect instead of a code change.

Silence pad comment and misleading 100× test comment — both reworded/annotated as suggested.

All green locally (typecheck, Biome, 347 tests incl. the new queue tests). Preview + offline export re-verified on macOS and Windows.

…ilence)

The video export maps source content at time τ to output τ/speed, holding its
first decoded frame over the pre-roll head (streamingDecoder decodeAll). The
offline audio path instead left-packed its first decoded sample to output 0, so
for a source whose first audio timestamp is non-zero (codec priming, or an audio
track that starts after the video) audio led video by ~T/speed across the first
segment. Feed that head gap as source-domain silence into segment 0's stretcher
(compressed by the segment speed) so real audio keeps its true source-time
position and stays aligned with video. No-op when the first timestamp is ~0, so
self-recorded content is unaffected; negative codec preroll is still discarded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rainyflash

Copy link
Copy Markdown
Author

Following up on the first-AudioData.timestamp point (f6c503a) — I dug into this further and there is a real, if narrow, issue worth fixing properly (my earlier "just a comment" was too quick).

The key is how the two paths position the pre-roll head. The video path (streamingDecoder.decodeAll) maps source content at time τ to output τ/speed and holds its first decoded frame over the head [0, T). The offline audio path instead left-packed its first decoded sample to output 0, so for a source whose first audio timestamp T is non-zero (AAC priming, or an audio track that starts after the video), audio led video by ~T/speed — visible once the first video frame changes, not while it's frozen.

The correct fix keeps raw timestamps (no re-anchor — that would desync the whole timeline, as noted): when segment 0's first sample lands at currentAbs > 0, I feed that head gap into the segment's stretcher as source-domain silence, so it's compressed by the segment speed exactly like the video's held head. Real audio then lands at output T/speed, matching video. It's a no-op when the first timestamp is ~0 (self-recorded content), negative preroll is still discarded, and the per-segment A/V-locked length is preserved.

I verified this against the actual frame-emission code (that video does hold-from-0 rather than subtract an origin) and stress-tested the WSOLA silence→audio boundary; residual imprecision is sub-grain and non-accumulating. Green locally (347 tests). Thanks for pushing on this one — it turned a "known minor asymmetry" into an actual correctness fix.

@EtienneLescot EtienneLescot merged commit 77618cc into getopenscreen:main Jul 11, 2026
12 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.

[Feature]: Raise the 16x playback-speed cap (frame-seek preview + offline audio for fast segments)

2 participants