Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 88 additions & 2 deletions src/components/video-editor/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>) => {
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 (
<div className="flex items-center gap-1">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
placeholder="--"
value={draft}
onFocus={() => 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"
/>
<span className="text-[11px] font-semibold text-slate-500">×</span>
</div>
);
}

const WALLPAPER_COUNT = 18;
const WALLPAPER_RELATIVE = Array.from(
Expand Down Expand Up @@ -537,7 +600,7 @@ export function SettingsPanel({
</span>
)}
</div>
<div className="grid grid-cols-7 gap-1.5">
<div className="grid grid-cols-5 gap-1.5">
{SPEED_OPTIONS.map((option) => {
const isActive = selectedSpeedValue === option.speed;
return (
Expand All @@ -562,6 +625,29 @@ export function SettingsPanel({
);
})}
</div>
<div className="mt-3">
<div className="flex items-center justify-between">
<span
className={cn("text-[11px]", selectedSpeedId ? "text-slate-500" : "text-slate-600")}
>
{t("speed.customPlaybackSpeed")}
</span>
{selectedSpeedId ? (
<CustomSpeedInput
value={selectedSpeedValue ?? 1}
onChange={(val) => onSpeedChange?.(val)}
onError={() => toast.error(t("speed.maxSpeedError"))}
/>
) : (
<div className="flex items-center gap-1 opacity-40">
<div className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-slate-600 text-center">
--
</div>
<span className="text-[11px] font-semibold text-slate-600">×</span>
</div>
)}
</div>
</div>
{!selectedSpeedId && (
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("speed.selectRegion")}</p>
)}
Expand Down
15 changes: 7 additions & 8 deletions src/components/video-editor/projectPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -219,14 +222,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): 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 {
Expand Down
14 changes: 13 additions & 1 deletion src/components/video-editor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/es/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/zh-CN/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"speed": {
"playbackSpeed": "播放速度",
"selectRegion": "选择要调整的速度区域",
"deleteRegion": "删除速度区域"
"deleteRegion": "删除速度区域",
"customPlaybackSpeed": "自定义播放速度",
"maxSpeedError": "速度不能超过 16×"
},
"trim": {
"deleteRegion": "删除剪辑区域"
Expand Down
Loading