diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 9ce15228..89d5457e 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -86,6 +86,7 @@ import {
getAspectRatioLabel,
getAspectRatioValue,
} from "@/utils/aspectRatioUtils";
+import { planClipSpeedChange } from "./clipSpeedChange";
import { ExtensionIcon } from "./ExtensionIcon";
import { calculateMp4ExportDimensions, calculateMp4SourceDimensions } from "./exportDimensions";
import { resolveSavingExportProgress } from "./exportProgressState";
@@ -3530,31 +3531,33 @@ export default function VideoEditor() {
if (!Number.isFinite(speed) || speed <= 0) {
return;
}
- const clip = clipRegions.find((c) => c.id === selectedClipId);
- if (!clip) return;
- const oldSpeed = Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1;
- const sourceDurationMs = (clip.endMs - clip.startMs) * oldSpeed;
- const newEndMs = Math.round(clip.startMs + sourceDurationMs / speed);
- const scaleFactor = oldSpeed / speed;
+ const plan = planClipSpeedChange({
+ clipRegions,
+ zoomRegions,
+ selectedClipId,
+ speed,
+ });
+ if (!plan) return;
- setClipRegions((prev) =>
- prev.map((c) => (c.id === selectedClipId ? { ...c, speed, endMs: newEndMs } : c)),
- );
- // Scale zoom regions that lie within this clip proportionally
- setZoomRegions((prev) =>
- prev.map((zoom) => {
- if (zoom.startMs < clip.startMs || zoom.startMs >= clip.endMs) return zoom;
- return {
- ...zoom,
- startMs: Math.round(
- clip.startMs + (zoom.startMs - clip.startMs) * scaleFactor,
- ),
- endMs: Math.round(clip.startMs + (zoom.endMs - clip.startMs) * scaleFactor),
- };
- }),
- );
+ if ("blockedReason" in plan) {
+ toast.warning(
+ plan.blockedReason === "clip-overlap"
+ ? t(
+ "editor.timeline.speedClipOverlap",
+ "Speed change would overlap the next clip. Move or split clips before slowing this section.",
+ )
+ : t(
+ "editor.timeline.speedZoomOverlap",
+ "Speed change would overlap another zoom. Move or delete the overlapping zoom first.",
+ ),
+ );
+ return;
+ }
+
+ setClipRegions(plan.clipRegions);
+ setZoomRegions(plan.zoomRegions);
},
- [selectedClipId, clipRegions],
+ [selectedClipId, clipRegions, zoomRegions, t],
);
const handleClipMutedChange = useCallback(
diff --git a/src/components/video-editor/clipSpeedChange.test.ts b/src/components/video-editor/clipSpeedChange.test.ts
new file mode 100644
index 00000000..d4f2b6c1
--- /dev/null
+++ b/src/components/video-editor/clipSpeedChange.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ type BlockedClipSpeedChange,
+ formatClipSpeedLabel,
+ planClipSpeedChange,
+} from "./clipSpeedChange";
+
+describe("formatClipSpeedLabel", () => {
+ it("returns labels only for non-default positive speeds", () => {
+ expect(formatClipSpeedLabel(1)).toBeNull();
+ expect(formatClipSpeedLabel(0)).toBeNull();
+ expect(formatClipSpeedLabel(-1)).toBeNull();
+ expect(formatClipSpeedLabel(Number.POSITIVE_INFINITY)).toBeNull();
+ expect(formatClipSpeedLabel(Number.NaN)).toBeNull();
+ expect(formatClipSpeedLabel(0.5)).toBe("0.5x");
+ expect(formatClipSpeedLabel(2)).toBe("2x");
+ });
+});
+
+describe("planClipSpeedChange", () => {
+ it("returns null for missing clips and invalid speeds", () => {
+ const clipRegions = [{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 1 }];
+
+ expect(
+ planClipSpeedChange({
+ clipRegions,
+ zoomRegions: [],
+ selectedClipId: "missing",
+ speed: 0.5,
+ }),
+ ).toBeNull();
+
+ for (const speed of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
+ expect(
+ planClipSpeedChange({
+ clipRegions,
+ zoomRegions: [],
+ selectedClipId: "clip-1",
+ speed,
+ }),
+ ).toBeNull();
+ }
+ });
+
+ it("extends an isolated clip when slowing it down", () => {
+ const result = planClipSpeedChange({
+ clipRegions: [{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 1 }],
+ zoomRegions: [],
+ selectedClipId: "clip-1",
+ speed: 0.5,
+ });
+
+ expect(result).toEqual({
+ clipRegions: [{ id: "clip-1", startMs: 0, endMs: 10_000, speed: 0.5 }],
+ zoomRegions: [],
+ });
+ });
+
+ it("shortens an isolated clip when speeding it up", () => {
+ const result = planClipSpeedChange({
+ clipRegions: [{ id: "clip-1", startMs: 0, endMs: 6_000, speed: 1 }],
+ zoomRegions: [],
+ selectedClipId: "clip-1",
+ speed: 2,
+ });
+
+ expect(result).toEqual({
+ clipRegions: [{ id: "clip-1", startMs: 0, endMs: 3_000, speed: 2 }],
+ zoomRegions: [],
+ });
+ });
+
+ it("treats invalid stored clip speed as 1x", () => {
+ const result = planClipSpeedChange({
+ clipRegions: [{ id: "clip-1", startMs: 0, endMs: 4_000, speed: Number.NaN }],
+ zoomRegions: [],
+ selectedClipId: "clip-1",
+ speed: 0.5,
+ });
+
+ expect(result).toEqual({
+ clipRegions: [{ id: "clip-1", startMs: 0, endMs: 8_000, speed: 0.5 }],
+ zoomRegions: [],
+ });
+ });
+
+ it("blocks slow speed changes that would overlap the next clip", () => {
+ const result = planClipSpeedChange({
+ clipRegions: [
+ { id: "clip-1", startMs: 0, endMs: 5_000, speed: 1 },
+ { id: "clip-2", startMs: 5_000, endMs: 10_000, speed: 1 },
+ ],
+ zoomRegions: [],
+ selectedClipId: "clip-1",
+ speed: 0.5,
+ }) as BlockedClipSpeedChange;
+
+ expect(result.blockedReason).toBe("clip-overlap");
+ });
+
+ it("scales zoom regions inside the changed clip", () => {
+ const result = planClipSpeedChange({
+ clipRegions: [{ id: "clip-1", startMs: 1_000, endMs: 5_000, speed: 1 }],
+ zoomRegions: [
+ { id: "zoom-1", startMs: 2_000, endMs: 3_000, depth: 2, focus: { cx: 0.5, cy: 0.5 } },
+ ],
+ selectedClipId: "clip-1",
+ speed: 0.5,
+ });
+
+ expect(result).toEqual({
+ clipRegions: [{ id: "clip-1", startMs: 1_000, endMs: 9_000, speed: 0.5 }],
+ zoomRegions: [
+ { id: "zoom-1", startMs: 3_000, endMs: 5_000, depth: 2, focus: { cx: 0.5, cy: 0.5 } },
+ ],
+ });
+ });
+
+ it("does not scale zoom regions that start outside the changed clip", () => {
+ const result = planClipSpeedChange({
+ clipRegions: [{ id: "clip-1", startMs: 2_000, endMs: 6_000, speed: 1 }],
+ zoomRegions: [
+ { id: "zoom-before", startMs: 1_000, endMs: 1_500, depth: 2, focus: { cx: 0.5, cy: 0.5 } },
+ { id: "zoom-after", startMs: 6_000, endMs: 6_500, depth: 2, focus: { cx: 0.5, cy: 0.5 } },
+ ],
+ selectedClipId: "clip-1",
+ speed: 0.5,
+ });
+
+ expect(result).toEqual({
+ clipRegions: [{ id: "clip-1", startMs: 2_000, endMs: 10_000, speed: 0.5 }],
+ zoomRegions: [
+ { id: "zoom-before", startMs: 1_000, endMs: 1_500, depth: 2, focus: { cx: 0.5, cy: 0.5 } },
+ { id: "zoom-after", startMs: 6_000, endMs: 6_500, depth: 2, focus: { cx: 0.5, cy: 0.5 } },
+ ],
+ });
+ });
+
+ it("blocks speed changes that would make scaled zooms overlap unchanged zooms", () => {
+ const result = planClipSpeedChange({
+ clipRegions: [{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 1 }],
+ zoomRegions: [
+ { id: "zoom-1", startMs: 2_000, endMs: 3_000, depth: 2, focus: { cx: 0.5, cy: 0.5 } },
+ { id: "zoom-2", startMs: 5_500, endMs: 6_500, depth: 3, focus: { cx: 0.5, cy: 0.5 } },
+ ],
+ selectedClipId: "clip-1",
+ speed: 0.5,
+ }) as BlockedClipSpeedChange;
+
+ expect(result.blockedReason).toBe("zoom-overlap");
+ });
+});
diff --git a/src/components/video-editor/clipSpeedChange.ts b/src/components/video-editor/clipSpeedChange.ts
new file mode 100644
index 00000000..b155627c
--- /dev/null
+++ b/src/components/video-editor/clipSpeedChange.ts
@@ -0,0 +1,99 @@
+import type { ClipRegion, ZoomRegion } from "./types";
+
+export type ClipSpeedChangeBlockReason = "clip-overlap" | "zoom-overlap";
+
+export interface ClipSpeedChangePlan {
+ clipRegions: ClipRegion[];
+ zoomRegions: ZoomRegion[];
+}
+
+export interface BlockedClipSpeedChange {
+ blockedReason: ClipSpeedChangeBlockReason;
+}
+
+export function formatClipSpeedLabel(speed: number): string | null {
+ if (!Number.isFinite(speed) || speed <= 0 || speed === 1) {
+ return null;
+ }
+
+ return `${Number.isInteger(speed) ? speed.toFixed(0) : speed.toString()}x`;
+}
+
+function spansOverlap(
+ left: { startMs: number; endMs: number },
+ right: { startMs: number; endMs: number },
+): boolean {
+ return left.startMs < right.endMs && left.endMs > right.startMs;
+}
+
+export function planClipSpeedChange(params: {
+ clipRegions: ClipRegion[];
+ zoomRegions: ZoomRegion[];
+ selectedClipId: string;
+ speed: number;
+}): ClipSpeedChangePlan | BlockedClipSpeedChange | null {
+ const { clipRegions, zoomRegions, selectedClipId, speed } = params;
+ if (!selectedClipId || !Number.isFinite(speed) || speed <= 0) {
+ return null;
+ }
+
+ const clip = clipRegions.find((candidate) => candidate.id === selectedClipId);
+ if (!clip) {
+ return null;
+ }
+
+ const oldSpeed = Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1;
+ const sourceDurationMs = Math.max(0, clip.endMs - clip.startMs) * oldSpeed;
+ const newEndMs = Math.round(clip.startMs + sourceDurationMs / speed);
+ const nextClip = clipRegions
+ .filter((candidate) => candidate.id !== selectedClipId && candidate.startMs >= clip.endMs)
+ .sort((left, right) => left.startMs - right.startMs)[0];
+
+ if (nextClip && newEndMs > nextClip.startMs) {
+ return { blockedReason: "clip-overlap" };
+ }
+
+ const scaleFactor = oldSpeed / speed;
+ const nextZoomRegions = zoomRegions.map((zoom) => {
+ if (zoom.startMs < clip.startMs || zoom.startMs >= clip.endMs) {
+ return zoom;
+ }
+
+ return {
+ ...zoom,
+ startMs: Math.round(clip.startMs + (zoom.startMs - clip.startMs) * scaleFactor),
+ endMs: Math.round(clip.startMs + (zoom.endMs - clip.startMs) * scaleFactor),
+ };
+ });
+
+ const changedZoomIds = new Set(
+ nextZoomRegions
+ .filter((zoom, index) => {
+ const previous = zoomRegions[index];
+ return previous.startMs !== zoom.startMs || previous.endMs !== zoom.endMs;
+ })
+ .map((zoom) => zoom.id),
+ );
+
+ const hasZoomOverlap = nextZoomRegions.some((zoom, index) =>
+ nextZoomRegions.some(
+ (other, otherIndex) =>
+ index !== otherIndex &&
+ (changedZoomIds.has(zoom.id) || changedZoomIds.has(other.id)) &&
+ spansOverlap(zoom, other),
+ ),
+ );
+
+ if (hasZoomOverlap) {
+ return { blockedReason: "zoom-overlap" };
+ }
+
+ return {
+ clipRegions: clipRegions.map((candidate) =>
+ candidate.id === selectedClipId
+ ? { ...candidate, speed, endMs: newEndMs }
+ : candidate,
+ ),
+ zoomRegions: nextZoomRegions,
+ };
+}
diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx
index b38e42f7..5d335b8e 100644
--- a/src/components/video-editor/timeline/Item.tsx
+++ b/src/components/video-editor/timeline/Item.tsx
@@ -11,8 +11,9 @@ import {
import type { Span } from "dnd-timeline";
import { useItem } from "dnd-timeline";
import { useMemo } from "react";
-import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
+import { cn } from "@/lib/utils";
+import { formatClipSpeedLabel } from "../clipSpeedChange";
import AudioWaveform from "./components/waveform/AudioWaveform";
import type { AudioPeaksData } from "./core/timelineTypes";
import glassStyles from "./ItemGlass.module.css";
@@ -125,6 +126,7 @@ export default function Item({
const isSpeed = variant === "speed";
const isAudio = variant === "audio";
const showAudioWaveform = isAudio && Boolean(waveformPeaks);
+ const clipSpeedLabel = isClip ? formatClipSpeedLabel(speedValue ?? 1) : null;
const glassClass = isZoom
? glassStyles.glassPurple
@@ -234,6 +236,11 @@ export default function Item({
Clip
+ {clipSpeedLabel && (
+
+ {clipSpeedLabel}
+
+ )}
>
) : isSpeed ? (
<>
diff --git a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx
index 589f5630..5f1a8a45 100644
--- a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx
+++ b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx
@@ -1,31 +1,21 @@
import { Plus } from "@phosphor-icons/react";
import { useTimelineContext } from "dnd-timeline";
import {
+ type MouseEvent,
+ type MouseEventHandler,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
- type MouseEvent,
- type MouseEventHandler,
} from "react";
-import { cn } from "@/lib/utils";
import type {
SourceAudioTrackSettings,
SourceAudioTrackWithPeaks,
} from "@/components/video-editor/audio/audioTypes";
-import {
- getTimelineContentMinHeightPx,
- getTimelineRowsMinHeightPx,
- getTimelineViewportStretchFactor,
- TIMELINE_AXIS_HEIGHT_PX,
-} from "../../timelineLayout";
-import glassStyles from "../../ItemGlass.module.css";
-import Item from "../../Item";
-import Row from "../../Row";
+import { cn } from "@/lib/utils";
import { CLIP_ROW_ID, SOURCE_AUDIO_ROW_ID, ZOOM_ROW_ID } from "../../core/constants";
-import type { TimelineRenderItem } from "../../core/timelineTypes";
import {
getAnnotationTrackIndex,
getAnnotationTrackRowId,
@@ -34,10 +24,20 @@ import {
isAnnotationTrackRowId,
isAudioTrackRowId,
} from "../../core/rows";
+import type { TimelineRenderItem } from "../../core/timelineTypes";
+import { useTimelineAudioPeaks } from "../../hooks/useTimelineAudioPeaks";
+import Item from "../../Item";
+import glassStyles from "../../ItemGlass.module.css";
+import Row from "../../Row";
+import {
+ getTimelineContentMinHeightPx,
+ getTimelineRowsMinHeightPx,
+ getTimelineViewportStretchFactor,
+ TIMELINE_AXIS_HEIGHT_PX,
+} from "../../timelineLayout";
import TimelineAxis from "../axis/TimelineAxis";
import ClipMarkerOverlay from "../overlays/ClipMarkerOverlay";
import PlaybackCursor from "../playhead/PlaybackCursor";
-import { useTimelineAudioPeaks } from "../../hooks/useTimelineAudioPeaks";
const HINT_CLIP = "Press C to split clip";
const HINT_ANNOTATION = "Press A to add annotation";
@@ -386,6 +386,7 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({
isSelected={selectAllBlocksActive || item.id === selectedClipId}
onSelectId={onSelectClip}
variant="clip"
+ speedValue={item.speedValue}
isLoading={isLoading}
loadingLabel="Analyzing..."
>
diff --git a/src/components/video-editor/timeline/model/timelineModel.test.ts b/src/components/video-editor/timeline/model/timelineModel.test.ts
index f530f2d6..1f20052e 100644
--- a/src/components/video-editor/timeline/model/timelineModel.test.ts
+++ b/src/components/video-editor/timeline/model/timelineModel.test.ts
@@ -47,6 +47,21 @@ describe("timeline model", () => {
expect(items.find((i) => i.id === "au1")?.label).toBe("foo");
});
+ it("exposes clip speed for non-default speed labels", () => {
+ const items = buildTimelineItems({
+ zoomRegions: [],
+ clipRegions: [{ id: "c1", startMs: 0, endMs: 8000, speed: 0.5 }],
+ annotationRegions: [],
+ audioRegions: [],
+ });
+
+ expect(items[0]).toMatchObject({
+ id: "c1",
+ label: "Clip 1 0.5x",
+ speedValue: 0.5,
+ });
+ });
+
it("builds all variant labels for annotation and audio", () => {
expect(getAnnotationLabel({ ...BASE_ANNOTATION, type: "text", content: " " })).toBe(
"Empty text",
diff --git a/src/components/video-editor/timeline/model/timelineModel.ts b/src/components/video-editor/timeline/model/timelineModel.ts
index 96681351..60737062 100644
--- a/src/components/video-editor/timeline/model/timelineModel.ts
+++ b/src/components/video-editor/timeline/model/timelineModel.ts
@@ -1,10 +1,10 @@
+import { formatClipSpeedLabel } from "../../clipSpeedChange";
import type {
AnnotationRegion,
AudioRegion,
ClipRegion,
ZoomRegion,
} from "../../types";
-import type { TimelineRegionSpan, TimelineRenderItem } from "../core/timelineTypes";
import { CLIP_ROW_ID, ZOOM_ROW_ID } from "../core/constants";
import {
getAnnotationTrackIndex,
@@ -14,6 +14,7 @@ import {
isAnnotationTrackRowId,
isAudioTrackRowId,
} from "../core/rows";
+import type { TimelineRegionSpan, TimelineRenderItem } from "../core/timelineTypes";
export function getAnnotationLabel(region: AnnotationRegion): string {
if (region.type === "text") {
@@ -51,13 +52,15 @@ export function buildTimelineItems(params: {
const displayDurationMs = Math.max(0, region.endMs - region.startMs);
const speed = Number.isFinite(region.speed) && region.speed > 0 ? region.speed : 1;
const sourceEndMs = region.startMs + displayDurationMs * speed;
+ const speedLabel = formatClipSpeedLabel(speed);
return {
id: region.id,
rowId: CLIP_ROW_ID,
span: { start: region.startMs, end: region.endMs },
sourceSpan: { start: region.startMs, end: sourceEndMs },
- label: `Clip ${index + 1}`,
+ label: speedLabel ? `Clip ${index + 1} ${speedLabel}` : `Clip ${index + 1}`,
+ speedValue: speedLabel ? speed : undefined,
showSourceAudio: region.showSourceAudio,
muted: Boolean(region.muted),
variant: "clip",