diff --git a/src/components/ai-edition/v4/SpeedControl.test.tsx b/src/components/ai-edition/v4/SpeedControl.test.tsx index 4480e347c..57deb67c5 100644 --- a/src/components/ai-edition/v4/SpeedControl.test.tsx +++ b/src/components/ai-edition/v4/SpeedControl.test.tsx @@ -40,6 +40,14 @@ describe("SpeedControl", () => { expect(updateSpeedValue).toHaveBeenCalledWith("sp1", 12); }); + it("commits a speed typed with a multiplier suffix, like the zoom field", () => { + const { updateSpeedValue, field } = renderControl(2); + fireEvent.change(field, { target: { value: "1,25×" } }); + fireEvent.blur(field); + expect(updateSpeedValue).toHaveBeenCalledWith("sp1", 1.25); + expect(field).toHaveValue(""); + }); + it("commits on Enter as well as on blur, and only once", () => { const { updateSpeedValue, field } = renderControl(1); fireEvent.change(field, { target: { value: "7.5" } }); diff --git a/src/components/video-editor/customPlaybackSpeed.test.ts b/src/components/video-editor/customPlaybackSpeed.test.ts index 8f588f2c1..f14ae20c0 100644 --- a/src/components/video-editor/customPlaybackSpeed.test.ts +++ b/src/components/video-editor/customPlaybackSpeed.test.ts @@ -44,6 +44,36 @@ describe("parseCustomPlaybackSpeedInput", () => { }); }); + it.each([ + "1.25x", + "1.25×", + "1.25X", + "1,25×", + "1.25 x", + " 1.25x ", + ])("accepts a trailing multiplier suffix: %j", (draft) => { + // The field shows "2×"; zoom accepts the same suffix. + expect(parseCustomPlaybackSpeedInput(draft)).toEqual({ + status: "valid", + draft, + speed: 1.25, + }); + }); + + it.each(["1.25xx", "x1.25", "1x2"])("rejects a misplaced or repeated suffix: %j", (draft) => { + expect(parseCustomPlaybackSpeedInput(draft)).toEqual({ status: "empty", draft }); + }); + + it.each(["", " ", "x", "×"])("treats a blank or bare-suffix draft as empty: %j", (draft) => { + // Stripping the suffix leaves "", and Number("") is 0, which would read as too-slow. + expect(parseCustomPlaybackSpeedInput(draft)).toEqual({ status: "empty", draft }); + }); + + it("still applies the editor bounds to a suffixed speed", () => { + expect(parseCustomPlaybackSpeedInput("16.1x")).toEqual({ status: "too-fast", draft: "16.1x" }); + expect(parseCustomPlaybackSpeedInput("0.2×")).toEqual({ status: "too-slow", draft: "0.2×" }); + }); + it("accepts the maximum editor speed, the preview's own ceiling", () => { expect(parseCustomPlaybackSpeedInput("16")).toEqual({ status: "valid", diff --git a/src/components/video-editor/customPlaybackSpeed.ts b/src/components/video-editor/customPlaybackSpeed.ts index 03a16cae3..3e13efe03 100644 --- a/src/components/video-editor/customPlaybackSpeed.ts +++ b/src/components/video-editor/customPlaybackSpeed.ts @@ -12,7 +12,17 @@ export type CustomPlaybackSpeedInputResult = | { status: "valid"; draft: string; speed: PlaybackSpeed }; export function parseCustomPlaybackSpeedInput(draft: string): CustomPlaybackSpeedInputResult { - const normalized = Number(draft.replace(/,/g, ".")); + // The field's placeholder reads "2×", so a typed "1.25x" or "1.25×" is a speed, not noise. + // Same trailing suffix the zoom field accepts (`ZoomLevelControl`). + const sanitized = draft + .trim() + .replace(/,/g, ".") + .replace(/\s*[×x]$/i, ""); + // Number("") is 0, which would report a blank draft, or a bare "x", as too-slow. + if (sanitized === "") { + return { status: "empty", draft }; + } + const normalized = Number(sanitized); if (!Number.isFinite(normalized)) { return { status: "empty", draft };