From 3895ca985f845dd99c9f0af83af7d5aec3c9da20 Mon Sep 17 00:00:00 2001 From: Ishan Panta Date: Fri, 3 Apr 2026 08:37:16 +0545 Subject: [PATCH] [add] extend speed options with higher presets and custom speed input add 3x, 4x, 5x speed presets and a custom playback speed input field that accepts any integer value up to 16x. change PlaybackSpeed type from a fixed union to number with min/max constants and clamp utility. update project persistence to validate any speed in range instead of exact value matching. add i18n keys for en, es, zh-CN. closes #252 --- src/components/video-editor/SettingsPanel.tsx | 90 ++++++++++++++++++- .../video-editor/projectPersistence.ts | 15 ++-- src/components/video-editor/types.ts | 14 ++- src/i18n/locales/en/settings.json | 4 +- src/i18n/locales/es/settings.json | 4 +- src/i18n/locales/zh-CN/settings.json | 4 +- 6 files changed, 117 insertions(+), 14 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index f5afe35a4..cdb00ce5b 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -53,7 +53,70 @@ import type { WebcamLayoutPreset, ZoomDepth, } from "./types"; -import { SPEED_OPTIONS } from "./types"; +import { MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types"; + +function CustomSpeedInput({ + value, + onChange, + onError, +}: { + value: number; + onChange: (val: number) => void; + onError: () => void; +}) { + const isPreset = SPEED_OPTIONS.some((o) => o.speed === value); + const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value))); + const [isFocused, setIsFocused] = useState(false); + + const prevValue = useRef(value); + if (!isFocused && prevValue.current !== value) { + prevValue.current = value; + setDraft(isPreset ? "" : String(Math.round(value))); + } + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const digits = e.target.value.replace(/\D/g, ""); + if (digits === "") { + setDraft(""); + return; + } + const num = Number(digits); + if (num > MAX_PLAYBACK_SPEED) { + onError(); + return; + } + setDraft(digits); + if (num >= 1) onChange(num); + }, + [onChange, onError], + ); + + const handleBlur = useCallback(() => { + setIsFocused(false); + if (!draft || Number(draft) < 1) { + setDraft(isPreset ? "" : String(Math.round(value))); + } + }, [draft, isPreset, value]); + + return ( +
+ setIsFocused(true)} + onChange={handleChange} + onBlur={handleBlur} + onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()} + className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-[#d97706] text-center focus:outline-none focus:border-[#d97706]/40" + /> + × +
+ ); +} const WALLPAPER_COUNT = 18; const WALLPAPER_RELATIVE = Array.from( @@ -537,7 +600,7 @@ export function SettingsPanel({ )} -
+
{SPEED_OPTIONS.map((option) => { const isActive = selectedSpeedValue === option.speed; return ( @@ -562,6 +625,29 @@ export function SettingsPanel({ ); })}
+
+
+ + {t("speed.customPlaybackSpeed")} + + {selectedSpeedId ? ( + onSpeedChange?.(val)} + onError={() => toast.error(t("speed.maxSpeedError"))} + /> + ) : ( +
+
+ -- +
+ × +
+ )} +
+
{!selectedSpeedId && (

{t("speed.selectRegion")}

)} diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 99f1bbaaa..bfe69723e 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -5,6 +5,7 @@ import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils"; import { type AnnotationRegion, type CropRegion, + clampPlaybackSpeed, DEFAULT_ANNOTATION_POSITION, DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, @@ -14,6 +15,8 @@ import { DEFAULT_WEBCAM_LAYOUT_PRESET, DEFAULT_WEBCAM_POSITION, DEFAULT_ZOOM_DEPTH, + MAX_PLAYBACK_SPEED, + MIN_PLAYBACK_SPEED, type SpeedRegion, type TrimRegion, type WebcamLayoutPreset, @@ -219,14 +222,10 @@ export function normalizeProjectEditor(editor: Partial): Pro const endMs = Math.max(startMs + 1, rawEnd); const speed = - region.speed === 0.25 || - region.speed === 0.5 || - region.speed === 0.75 || - region.speed === 1.25 || - region.speed === 1.5 || - region.speed === 1.75 || - region.speed === 2 - ? region.speed + isFiniteNumber(region.speed) && + region.speed >= MIN_PLAYBACK_SPEED && + region.speed <= MAX_PLAYBACK_SPEED + ? clampPlaybackSpeed(region.speed) : DEFAULT_PLAYBACK_SPEED; return { diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index ce49f8e4b..d52e60ac6 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -132,7 +132,16 @@ export const DEFAULT_CROP_REGION: CropRegion = { height: 1, }; -export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2; +export type PlaybackSpeed = number; + +export const MIN_PLAYBACK_SPEED = 0.1; +// Anything above 16x causes the playhead to stall during preview +// due to the video decoder not being able to keep up. +export const MAX_PLAYBACK_SPEED = 16; + +export function clampPlaybackSpeed(speed: number): PlaybackSpeed { + return Math.round(Math.min(MAX_PLAYBACK_SPEED, Math.max(MIN_PLAYBACK_SPEED, speed)) * 100) / 100; +} export interface SpeedRegion { id: string; @@ -149,6 +158,9 @@ export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [ { speed: 1.5, label: "1.5×" }, { speed: 1.75, label: "1.75×" }, { speed: 2, label: "2×" }, + { speed: 3, label: "3×" }, + { speed: 4, label: "4×" }, + { speed: 5, label: "5×" }, ]; export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5; diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 36b7462aa..ad5f308c5 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -7,7 +7,9 @@ "speed": { "playbackSpeed": "Playback Speed", "selectRegion": "Select a speed region to adjust", - "deleteRegion": "Delete Speed Region" + "deleteRegion": "Delete Speed Region", + "customPlaybackSpeed": "Custom Playback Speed", + "maxSpeedError": "Speed can't go higher than 16×" }, "trim": { "deleteRegion": "Delete Trim Region" diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 467448013..f4c2d423d 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -7,7 +7,9 @@ "speed": { "playbackSpeed": "Velocidad de reproducción", "selectRegion": "Selecciona una región de velocidad para ajustar", - "deleteRegion": "Eliminar región de velocidad" + "deleteRegion": "Eliminar región de velocidad", + "customPlaybackSpeed": "Velocidad personalizada", + "maxSpeedError": "La velocidad no puede superar 16×" }, "trim": { "deleteRegion": "Eliminar región de recorte" diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 41bf55b3e..d38291ed3 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -7,7 +7,9 @@ "speed": { "playbackSpeed": "播放速度", "selectRegion": "选择要调整的速度区域", - "deleteRegion": "删除速度区域" + "deleteRegion": "删除速度区域", + "customPlaybackSpeed": "自定义播放速度", + "maxSpeedError": "速度不能超过 16×" }, "trim": { "deleteRegion": "删除剪辑区域"