diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index 33c5ccf7..a6410fc7 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -178,6 +178,8 @@ interface Window {
whisperExecutablePath?: string;
whisperModelPath: string;
language?: string;
+ durationMs?: number;
+ startTimeMs?: number;
}) => Promise<{
success: boolean;
cues?: CaptionCue[];
@@ -303,6 +305,7 @@ interface Window {
cancelCountdown: () => Promise<{ success: boolean }>;
getActiveCountdown: () => Promise<{ success: boolean; seconds: number | null }>;
onAutoCaptionProgress: (callback: (payload: { progress: number }) => void) => () => void;
+ onAutoCaptionChunk: (callback: (payload: { cues: CaptionCue[] }) => void) => () => void;
onCountdownTick: (callback: (seconds: number) => void) => () => void;
};
}
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index 4c6ac7ca..f159ccad 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -1473,6 +1473,8 @@ async function extractCaptionAudioSource(options: {
videoPath: string
ffmpegPath: string
wavPath: string
+ startTime?: number // in seconds
+ duration?: number // in seconds
}) {
const candidates = await resolveCaptionAudioCandidates(options.videoPath)
const attemptedCandidates: Array<{
@@ -1486,10 +1488,20 @@ async function extractCaptionAudioSource(options: {
for (const candidate of candidates) {
try {
await ensureReadableFile(candidate.path, 'video file')
- console.log('[auto-captions] Extracting audio from:', candidate.path)
+ console.log('[auto-captions] Extracting audio from:', candidate.path, options.startTime ? `at ${options.startTime}s` : '')
+
+ const ffmpegArgs = ['-y'];
+ if (options.startTime !== undefined) {
+ ffmpegArgs.push('-ss', options.startTime.toString());
+ }
+ if (options.duration !== undefined) {
+ ffmpegArgs.push('-t', options.duration.toString());
+ }
+ ffmpegArgs.push('-i', candidate.path, '-map', '0:a:0', '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', options.wavPath);
+
await execFileAsync(
options.ffmpegPath,
- ['-y', '-i', candidate.path, '-map', '0:a:0', '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', options.wavPath],
+ ffmpegArgs,
{ timeout: 5 * 60 * 1000, maxBuffer: 20 * 1024 * 1024 },
)
console.log('[auto-captions] Audio extracted successfully to:', options.wavPath)
@@ -1518,6 +1530,8 @@ async function generateAutoCaptionsFromVideo(
whisperExecutablePath?: string;
whisperModelPath: string;
language?: string;
+ durationMs?: number;
+ startTimeMs?: number;
},
) {
const ffmpegPath = getFfmpegBinaryPath()
@@ -1531,79 +1545,130 @@ async function generateAutoCaptionsFromVideo(
await ensureReadableFile(whisperExecutablePath, 'whisper executable')
await ensureReadableFile(whisperModelPath, 'whisper model')
- console.log('[auto-captions] Starting caption generation sequence')
+ // Constants for segmentation
+ const CHUNK_SIZE_MS = 5 * 60 * 1000; // 5 minutes
+ const OVERLAP_MS = 10 * 1000; // 10 seconds overlap for word boundaries
+
+ const startTimeMs = options.startTimeMs || 0;
+ const totalDurationMs = options.durationMs || 0;
+ const endTimeMs = totalDurationMs > 0 ? startTimeMs + totalDurationMs : Infinity;
+
+ console.log('[auto-captions] Starting segmented caption generation sequence')
console.log('[auto-captions] Video:', normalizedVideoPath)
- console.log('[auto-captions] Runtime:', whisperExecutablePath)
- console.log('[auto-captions] Model:', whisperModelPath)
- console.log('[auto-captions] Language:', options.language || 'auto')
+ console.log('[auto-captions] Range:', `${(startTimeMs/1000).toFixed(2)}s - ${totalDurationMs ? `${((startTimeMs + totalDurationMs)/1000).toFixed(2)}s` : 'End'}`)
- const tempBase = path.join(app.getPath('temp'), `recordly-captions-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
- const wavPath = `${tempBase}.wav`
- const outputBase = `${tempBase}-whisper`
- const srtPath = `${outputBase}.srt`
- const jsonPath = `${outputBase}.json`
+ const allCues: any[] = [];
+ let chunkCount = 1;
+ if (totalDurationMs > 0) {
+ chunkCount = Math.ceil(totalDurationMs / CHUNK_SIZE_MS);
+ }
- try {
- const audioSource = await extractCaptionAudioSource({
- videoPath: normalizedVideoPath,
- ffmpegPath,
- wavPath,
- })
+ let audioSourceLabel = 'Unknown';
- const language = options.language && options.language.trim() ? options.language.trim() : 'auto'
- const whisperBaseArgs = [
- '-m', whisperModelPath,
- '-f', wavPath,
- '-osrt',
- '-of', outputBase,
- '-l', language,
- '-np',
- ]
+ for (let offsetMs = startTimeMs; offsetMs < endTimeMs; offsetMs += CHUNK_SIZE_MS) {
+ const chunkIndex = Math.floor((offsetMs - startTimeMs) / CHUNK_SIZE_MS);
+ const tempBase = path.join(app.getPath('temp'), `recordly-captions-chunk-${chunkIndex}-${Date.now()}`)
+ const wavPath = `${tempBase}.wav`
+ const outputBase = `${tempBase}-whisper`
+ const srtPath = `${outputBase}.srt`
+ const jsonPath = `${outputBase}.json`
- let jsonEnabled = true
try {
- console.log('[auto-captions] Running Whisper with JSON output...')
- await runWhisperWithProgress(whisperExecutablePath, [...whisperBaseArgs, '-ojf'], (progress) => {
- webContents.send('auto-caption-progress', { progress })
+ console.log(`[auto-captions] Processing chunk ${chunkIndex + 1}/${chunkCount || '?'} at offset ${offsetMs / 1000}s`)
+
+ const audioSource = await extractCaptionAudioSource({
+ videoPath: normalizedVideoPath,
+ ffmpegPath,
+ wavPath,
+ startTime: offsetMs / 1000,
+ duration: (CHUNK_SIZE_MS + OVERLAP_MS) / 1000
})
- console.log('[auto-captions] Whisper JSON output generated.')
- } catch (error) {
- if (!shouldRetryWhisperWithoutJson(error)) {
- throw error
+ audioSourceLabel = audioSource.label;
+
+ const language = options.language && options.language.trim() ? options.language.trim() : 'auto'
+ const whisperBaseArgs = [
+ '-m', whisperModelPath,
+ '-f', wavPath,
+ '-osrt',
+ '-of', outputBase,
+ '-l', language,
+ '-np',
+ ]
+
+ let jsonEnabled = true
+ const updateChunkProgress = (progress: number) => {
+ if (totalDurationMs > 0) {
+ const totalProgress = (offsetMs / totalDurationMs * 100) + (progress / (totalDurationMs / CHUNK_SIZE_MS));
+ webContents.send('auto-caption-progress', { progress: Math.min(99, totalProgress) })
+ } else {
+ webContents.send('auto-caption-progress', { progress })
+ }
+ };
+
+ try {
+ await runWhisperWithProgress(whisperExecutablePath, [...whisperBaseArgs, '-ojf'], updateChunkProgress)
+ } catch (error) {
+ if (!shouldRetryWhisperWithoutJson(error)) throw error
+ jsonEnabled = false
+ console.warn(`[auto-captions] Whisper runtime error, retrying with SRT: ${error}`)
+ await runWhisperWithProgress(whisperExecutablePath, whisperBaseArgs, updateChunkProgress)
}
- jsonEnabled = false
- console.warn('[auto-captions] Whisper runtime does not support JSON full output, retrying with SRT only:', error)
- console.log('[auto-captions] Running Whisper with SRT output...')
- await runWhisperWithProgress(whisperExecutablePath, whisperBaseArgs, (progress) => {
- webContents.send('auto-caption-progress', { progress })
- })
- console.log('[auto-captions] Whisper SRT output generated.')
- }
+ let cues = jsonEnabled
+ ? parseWhisperJsonCues(await fs.readFile(jsonPath, 'utf-8'))
+ : parseSrtCues(await fs.readFile(srtPath, 'utf-8'))
+
+ if (cues.length === 0 && !jsonEnabled) {
+ // If JSON failed, SRT might be empty or not yet read?
+ try { cues = parseSrtCues(await fs.readFile(srtPath, 'utf-8')); } catch { /* ignore */ }
+ }
- const timedCues = jsonEnabled
- ? parseWhisperJsonCues(await fs.readFile(jsonPath, 'utf-8'))
- : []
- const cues = timedCues.length > 0
- ? timedCues
- : parseSrtCues(await fs.readFile(srtPath, 'utf-8'))
- if (cues.length === 0) {
- console.error('[auto-captions] No cues were parsed from Whisper output.')
- throw new Error('Whisper completed, but no caption cues were produced.')
- }
+ // Adjust timings and deduplicate
+ const adjustedCues = cues
+ .map(cue => ({
+ ...cue,
+ startMs: cue.startMs + offsetMs,
+ endMs: cue.endMs + offsetMs
+ }))
+ // Only keep cues that START within this chunk's main window (prevent overlap duplicates)
+ // Except for the very last chunk where we take everything
+ .filter(cue => {
+ const isLastChunk = totalDurationMs > 0 && (offsetMs + CHUNK_SIZE_MS >= totalDurationMs);
+ if (isLastChunk) return true;
+ return cue.startMs < offsetMs + CHUNK_SIZE_MS;
+ });
- console.log(`[auto-captions] Successfully generated ${cues.length} cues.`)
+ if (adjustedCues.length > 0) {
+ console.log(`[auto-captions] Chunk ${chunkIndex + 1} produced ${adjustedCues.length} adjusted cues.`)
+ allCues.push(...adjustedCues);
+ webContents.send('auto-caption-chunk', { cues: adjustedCues });
+ }
- return {
- cues,
- audioSourceLabel: audioSource.label,
+ // If we don't know duration and this was a short chunk, we might be at the end
+ // Actually, FFmpeg will just produce a short file if duration is past EOS.
+ const stats = await fs.stat(wavPath).catch(() => null);
+ if (stats && stats.size < 1000) { // Tiny audio file means we hit the end
+ break;
+ }
+
+ if (totalDurationMs > 0 && offsetMs + CHUNK_SIZE_MS >= totalDurationMs) {
+ break;
+ }
+
+ } finally {
+ await Promise.allSettled([
+ fs.rm(wavPath, { force: true }),
+ fs.rm(srtPath, { force: true }),
+ fs.rm(jsonPath, { force: true }),
+ ])
}
- } finally {
- await Promise.allSettled([
- fs.rm(wavPath, { force: true }),
- fs.rm(srtPath, { force: true }),
- fs.rm(jsonPath, { force: true }),
- ])
+ }
+
+ console.log(`[auto-captions] Generation complete. Total cues: ${allCues.length}`)
+ webContents.send('auto-caption-progress', { progress: 100 })
+ return {
+ cues: allCues,
+ audioSourceLabel,
}
}
diff --git a/electron/preload.ts b/electron/preload.ts
index 1ff30845..462cdb5f 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -209,6 +209,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.on("auto-caption-progress", listener);
return () => ipcRenderer.removeListener("auto-caption-progress", listener);
},
+ onAutoCaptionChunk: (callback: (payload: { cues: CaptionCue[] }) => void) => {
+ const listener = (_event: Electron.IpcRendererEvent, payload: { cues: CaptionCue[] }) =>
+ callback(payload);
+ ipcRenderer.on("auto-caption-chunk", listener);
+ return () => ipcRenderer.removeListener("auto-caption-chunk", listener);
+ },
setCurrentVideoPath: (path: string) => {
return ipcRenderer.invoke("set-current-video-path", path);
},
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 7f0d9b99..604b2860 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -228,6 +228,7 @@ interface SettingsPanelProps {
selectedSpeedValue?: PlaybackSpeed | null;
onSpeedChange?: (speed: PlaybackSpeed) => void;
onSpeedDelete?: (id: string) => void;
+ timeSelection?: { startMs: number; endMs: number } | null;
}
export default SettingsPanel;
@@ -550,9 +551,11 @@ export function SettingsPanel({
selectedSpeedValue,
onSpeedChange,
onSpeedDelete,
+ timeSelection,
}: SettingsPanelProps) {
const tSettings = useScopedT("settings");
const { t } = useI18n();
+
const isBackgroundPanel = panelMode === "background";
const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []);
const [builtInWallpapers, setBuiltInWallpapers] =
@@ -1469,6 +1472,36 @@ export function SettingsPanel({
+
+
+ Generation Range
+
+ val &&
+ onAutoCaptionSettingsChange?.({
+ ...autoCaptionSettings!,
+ generationRange: val as any,
+ })
+ }
+ className="justify-start gap-1"
+ >
+
+ Full Video
+
+
+ Selected Timeline {timeSelection ? `(${(timeSelection.startMs / 1000).toFixed(1)}s - ${(timeSelection.endMs / 1000).toFixed(1)}s)` : ""}
+
+
+
+
@@ -3507,7 +3544,6 @@ export default function VideoEditor() {
aspectRatio={aspectRatio}
onAspectRatioChange={setAspectRatio}
selectedAnnotationId={selectedAnnotationId}
- annotationRegions={annotationRegions}
onSeek={(time) => videoPlaybackRef.current?.seek(time)}
autoCaptions={autoCaptions}
onAutoCaptionsChange={setAutoCaptions}
@@ -3541,6 +3577,7 @@ export default function VideoEditor() {
onSpeedDelete={handleSpeedDelete}
selectedCaptionId={selectedCaptionId}
onSelectCaption={setSelectedCaptionId}
+ timeSelection={timeSelection}
/>
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts
index 3a50e4ec..206d44d8 100644
--- a/src/components/video-editor/projectPersistence.ts
+++ b/src/components/video-editor/projectPersistence.ts
@@ -498,6 +498,11 @@ export function normalizeProjectEditor(editor: Partial): Pro
selectedModel: typeof rawAutoCaptionSettings.selectedModel === "string"
? rawAutoCaptionSettings.selectedModel
: DEFAULT_AUTO_CAPTION_SETTINGS.selectedModel,
+ generationRange:
+ rawAutoCaptionSettings.generationRange === "full" ||
+ rawAutoCaptionSettings.generationRange === "selected"
+ ? rawAutoCaptionSettings.generationRange
+ : "full",
};
const rawCropX = isFiniteNumber(editor.cropRegion?.x)
diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx
index a1e02540..efc1c16e 100644
--- a/src/components/video-editor/timeline/Item.tsx
+++ b/src/components/video-editor/timeline/Item.tsx
@@ -14,7 +14,7 @@ interface ItemProps {
onSelect?: () => void;
zoomDepth?: number;
speedValue?: number;
- variant?: 'zoom' | 'trim' | 'annotation' | 'speed' | 'audio' | 'caption';
+ variant?: 'zoom' | 'trim' | 'annotation' | 'speed' | 'audio' | 'caption' | 'caption-range';
}
// Map zoom depth to multiplier labels
@@ -59,6 +59,7 @@ export default function Item({
const isSpeed = variant === 'speed';
const isAudio = variant === 'audio';
const isCaption = variant === 'caption';
+ const isCaptionRange = variant === 'caption-range';
const glassClass = isZoom
? glassStyles.glassGreen
@@ -70,6 +71,8 @@ export default function Item({
? glassStyles.glassPurple
: isCaption
? glassStyles.glassCyan
+ : isCaptionRange
+ ? glassStyles.glassCyanDashed
: glassStyles.glassYellow;
const endCapColor = isZoom
@@ -82,6 +85,8 @@ export default function Item({
? '#a855f7'
: isCaption
? '#0891b2'
+ : isCaptionRange
+ ? '#06b6d4'
: '#B4A046';
const timeLabel = useMemo(
diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css
index b89936b3..deedc7a9 100644
--- a/src/components/video-editor/timeline/ItemGlass.module.css
+++ b/src/components/video-editor/timeline/ItemGlass.module.css
@@ -175,11 +175,38 @@
.glassAmber.selected .zoomEndCap,
.glassPurple:hover .zoomEndCap,
.glassPurple.selected .zoomEndCap,
-.glassCyan:hover .zoomEndCap,
-.glassCyan.selected .zoomEndCap {
+.glassCyan.selected .zoomEndCap,
+.glassCyanDashed:hover .zoomEndCap,
+.glassCyanDashed.selected .zoomEndCap {
opacity: 1;
}
+.glassCyanDashed {
+ position: relative;
+ border-radius: 8px;
+ -corner-smoothing: antialiased;
+ background: rgba(8, 145, 178, 0.05);
+ border: 1px dashed rgba(8, 145, 178, 0.5);
+ box-shadow: 0 2px 12px 0 rgba(8, 145, 178, 0.05) inset;
+ margin: 1px 0;
+ backdrop-filter: blur(2px);
+ -webkit-backdrop-filter: blur(2px);
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.glassCyanDashed:hover {
+ background: rgba(8, 145, 178, 0.1);
+ border-color: rgba(8, 145, 178, 0.7);
+}
+
+.glassCyanDashed.selected {
+ background: rgba(8, 145, 178, 0.2);
+ border-color: #0891b2;
+ border-style: solid;
+ box-shadow: 0 0 0 1px #0891b2, 0 4px 20px 0 rgba(8, 145, 178, 0.15) inset;
+ z-index: 10;
+}
+
.zoomEndCap.left {
left: 0;
cursor: ew-resize;
diff --git a/src/components/video-editor/timeline/KeyframeMarkers.tsx b/src/components/video-editor/timeline/KeyframeMarkers.tsx
index d5c18be3..59bbcb52 100644
--- a/src/components/video-editor/timeline/KeyframeMarkers.tsx
+++ b/src/components/video-editor/timeline/KeyframeMarkers.tsx
@@ -23,7 +23,7 @@ const KeyframeMarkers: React.FC = ({
videoDurationMs,
timelineRef
}) => {
- const { sidebarWidth, range, valueToPixels, pixelsToValue } = useTimelineContext();
+ const { sidebarWidth = 0, range, valueToPixels, pixelsToValue } = useTimelineContext();
const [draggingKeyframeId, setDraggingKeyframeId] = useState(null);
useEffect(() => {
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx
index 6c368d2d..87088464 100644
--- a/src/components/video-editor/timeline/TimelineEditor.tsx
+++ b/src/components/video-editor/timeline/TimelineEditor.tsx
@@ -23,7 +23,7 @@ import Row from "./Row";
import Item from "./Item";
import KeyframeMarkers from "./KeyframeMarkers";
import type { Range, Span } from "dnd-timeline";
-import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, AudioRegion, CursorTelemetryPoint, ZoomFocus, CaptionCue } from "../types";
+import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, AudioRegion, CursorTelemetryPoint, ZoomFocus, CaptionCue, TimeSelection } from "../types";
import { toFileUrl } from "../projectPersistence";
import { detectInteractionCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils";
@@ -36,6 +36,7 @@ const CAPTION_ROW_ID = "row-caption";
const FALLBACK_RANGE_MS = 1000;
const TARGET_MARKER_COUNT = 12;
const SUGGESTION_SPACING_MS = 1800;
+const DRAG_THRESHOLD_PX = 5;
interface TimelineEditorProps {
videoDuration: number;
@@ -74,7 +75,7 @@ interface TimelineEditorProps {
onAudioDelete?: (id: string) => void;
selectedAudioId?: string | null;
onSelectAudio?: (id: string | null) => void;
- autoCaptions?: any[];
+ autoCaptions?: CaptionCue[];
onCaptionSpanChange?: (id: string, span: Span) => void;
selectedCaptionId?: string | null;
onSelectCaption?: (id: string | null) => void;
@@ -82,6 +83,8 @@ interface TimelineEditorProps {
onAspectRatioChange: (aspectRatio: AspectRatio) => void;
onOpenCropEditor?: () => void;
isCropped?: boolean;
+ timeSelection?: TimeSelection | null;
+ onTimeSelectionChange?: (selection: TimeSelection | null) => void;
}
interface TimelineScaleConfig {
@@ -219,7 +222,7 @@ function PlaybackCursor({
timelineRef: React.RefObject;
keyframes?: { id: string; time: number }[];
}) {
- const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
+ const { sidebarWidth = 0, direction, range, valueToPixels, pixelsToValue } = useTimelineContext();
const sideProperty = direction === "rtl" ? "right" : "left";
const [isDragging, setIsDragging] = useState(false);
@@ -331,7 +334,7 @@ function TimelineAxis({
videoDurationMs: number;
currentTimeMs: number;
}) {
- const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext();
+ const { sidebarWidth = 0, direction, range, valueToPixels } = useTimelineContext();
const sideProperty = direction === "rtl" ? "right" : "left";
const { intervalMs } = useMemo(
@@ -394,10 +397,18 @@ function TimelineAxis({
return (
{
+ // Also allow starting selection from the ruler
+ (e.currentTarget.parentElement as any)?.__handleMouseDown?.(e);
+ }}
+ onClick={(e) => {
+ // Also allow seeking/clearing from the ruler
+ (e.currentTarget.parentElement as any)?.__handleTimelineClick?.(e);
+ }}
>
{/* Minor Ticks */}
{markers.minorTicks.map((time) => {
@@ -464,6 +475,8 @@ function Timeline({
selectAllBlocksActive = false,
onClearBlockSelection,
keyframes = [],
+ timeSelection,
+ onTimeSelectionChange,
}: {
items: TimelineRenderItem[];
videoDurationMs: number;
@@ -484,31 +497,119 @@ function Timeline({
selectAllBlocksActive?: boolean;
onClearBlockSelection?: () => void;
keyframes?: { id: string; time: number }[];
+ timeSelection?: TimeSelection | null;
+ onTimeSelectionChange?: (selection: TimeSelection | null) => void;
}) {
- const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
+ const { setTimelineRef, style, sidebarWidth = 0, range, pixelsToValue, valueToPixels } = useTimelineContext();
const localTimelineRef = useRef
(null);
const setRefs = useCallback(
(node: HTMLDivElement | null) => {
- setTimelineRef(node);
- localTimelineRef.current = node;
+ if (localTimelineRef.current !== node) {
+ setTimelineRef(node);
+ localTimelineRef.current = node;
+ }
},
[setTimelineRef],
);
+ const isDraggingSelectionRef = useRef(false);
+ const selectionAnchorMsRef = useRef(null);
+ const initialMouseDownPosRef = useRef<{ x: number; y: number } | null>(null);
+
+ const handleMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ if (videoDurationMs <= 0) return;
+
+ // Capture the rect NOW — e.currentTarget becomes null after React's
+ // synthetic event is processed and must not be read inside async closures.
+ const capturedRect = e.currentTarget.getBoundingClientRect();
+ const clickX = e.clientX - capturedRect.left - sidebarWidth;
+
+ if (clickX < 0) return;
+
+ const relativeMs = pixelsToValue(clickX);
+ const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
+
+ initialMouseDownPosRef.current = { x: e.clientX, y: e.clientY };
+ isDraggingSelectionRef.current = false; // Reset drag flag
+
+ if (e.shiftKey) {
+ // Shift+mousedown: anchor to the far edge of the existing selection,
+ // or to the current playhead if there is no selection yet.
+ let anchor = currentTimeMs;
+ if (timeSelection) {
+ const distToStart = Math.abs(timeSelection.startMs - absoluteMs);
+ const distToEnd = Math.abs(timeSelection.endMs - absoluteMs);
+ anchor = distToStart > distToEnd ? timeSelection.startMs : timeSelection.endMs;
+ }
+
+ selectionAnchorMsRef.current = anchor;
+ const start = Math.min(anchor, absoluteMs);
+ const end = Math.max(anchor, absoluteMs);
+ onTimeSelectionChange?.({ startMs: start, endMs: end });
+ } else {
+ // Plain drag: anchor starts at the click point itself
+ selectionAnchorMsRef.current = absoluteMs;
+ onTimeSelectionChange?.({ startMs: absoluteMs, endMs: absoluteMs });
+ }
+
+ const handleGlobalMouseMove = (moveEvent: MouseEvent) => {
+ if (selectionAnchorMsRef.current === null || initialMouseDownPosRef.current === null) return;
+
+ const dx = Math.abs(moveEvent.clientX - initialMouseDownPosRef.current.x);
+ const dy = Math.abs(moveEvent.clientY - initialMouseDownPosRef.current.y);
+
+ if (dx > DRAG_THRESHOLD_PX || dy > DRAG_THRESHOLD_PX) {
+ isDraggingSelectionRef.current = true;
+ }
+
+ // Use the captured rect — safe to read from an async listener
+ const moveX = moveEvent.clientX - capturedRect.left - sidebarWidth;
+ const moveRelativeMs = pixelsToValue(moveX);
+ const moveAbsoluteMs = Math.max(0, Math.min(range.start + moveRelativeMs, videoDurationMs));
+
+ const start = Math.min(selectionAnchorMsRef.current, moveAbsoluteMs);
+ const end = Math.max(selectionAnchorMsRef.current, moveAbsoluteMs);
+
+ onTimeSelectionChange?.({ startMs: start, endMs: end });
+ };
+
+ const handleGlobalMouseUp = () => {
+ selectionAnchorMsRef.current = null;
+ initialMouseDownPosRef.current = null;
+ window.removeEventListener("mousemove", handleGlobalMouseMove);
+ window.removeEventListener("mouseup", handleGlobalMouseUp);
+ };
+
+ window.addEventListener("mousemove", handleGlobalMouseMove);
+ window.addEventListener("mouseup", handleGlobalMouseUp);
+ },
+ [range.start, sidebarWidth, pixelsToValue, videoDurationMs, onTimeSelectionChange, timeSelection, currentTimeMs],
+ );
+
const handleTimelineClick = useCallback(
(e: React.MouseEvent) => {
+ // If a drag occurred, swallow the click entirely
+ if (isDraggingSelectionRef.current) {
+ isDraggingSelectionRef.current = false;
+ return;
+ }
+
if (!onSeek || videoDurationMs <= 0) return;
- // Only clear selection if clicking on empty space (not on items)
- // This is handled by event propagation - items stop propagation
- onSelectZoom?.(null);
- onSelectTrim?.(null);
- onSelectAnnotation?.(null);
- onSelectSpeed?.(null);
- onSelectAudio?.(null);
- onSelectCaption?.(null);
- onClearBlockSelection?.();
+ // Shift+click: the selection was already updated in mousedown — don't seek or clear
+ if (e.shiftKey) return;
+
+ // Plain click: clear selection and deselect all blocks, then seek
+ onTimeSelectionChange?.(null);
+ onSelectZoom?.(null);
+ onSelectTrim?.(null);
+ onSelectAnnotation?.(null);
+ onSelectSpeed?.(null);
+ onSelectAudio?.(null);
+ onSelectCaption?.(null);
+ onClearBlockSelection?.();
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
@@ -517,10 +618,19 @@ function Timeline({
const relativeMs = pixelsToValue(clickX);
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
- const timeInSeconds = absoluteMs / 1000;
- onSeek(timeInSeconds);
- }, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, onSelectAudio, onSelectCaption, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
+ onSeek(absoluteMs / 1000);
+ },
+ [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, onSelectAudio, onSelectCaption, videoDurationMs, sidebarWidth, range.start, pixelsToValue, onTimeSelectionChange, onClearBlockSelection],
+ );
+
+ useEffect(() => {
+ if (localTimelineRef.current) {
+ // Expose handlers for internal components like Axis
+ (localTimelineRef.current as any).__handleMouseDown = handleMouseDown;
+ (localTimelineRef.current as any).__handleTimelineClick = handleTimelineClick;
+ }
+ }, [handleMouseDown, handleTimelineClick]);
const zoomItems = items.filter(item => item.rowId === ZOOM_ROW_ID);
const trimItems = items.filter(item => item.rowId === TRIM_ROW_ID);
@@ -534,8 +644,19 @@ function Timeline({
ref={setRefs}
style={style}
className="select-none bg-[#17171a] h-full min-h-0 relative cursor-pointer group flex flex-col"
+ onMouseDown={handleMouseDown}
onClick={handleTimelineClick}
>
+ {timeSelection && (
+ range.end) ? 'none' : 'block'
+ }}
+ />
+ )}
))}
-
+
+
{captionItems.map((item) => (
- loadEditorPreferences(), []);
@@ -824,6 +948,7 @@ export default function TimelineEditor({
onSelectAudio(null);
}, [selectedAudioId, onAudioDelete, onSelectAudio]);
+
const clearSelectedBlocks = useCallback(() => {
onSelectZoom(null);
onSelectTrim?.(null);
@@ -896,6 +1021,7 @@ export default function TimelineEditor({
onSelectAudio?.(id);
}, [onSelectAudio]);
+
const handleSelectCaption = useCallback((id: string | null) => {
setSelectAllBlocksActive(false);
onSelectCaption?.(id);
@@ -1046,23 +1172,23 @@ export default function TimelineEditor({
// Always place zoom at playhead
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
- // Find the next zoom region after the playhead
const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs);
const nextRegion = sorted.find(region => region.startMs > startPos);
const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
- // Check if playhead is inside any zoom region
- const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs);
- if (isOverlapping || gapToNext <= 0) {
- toast.error("Cannot place zoom here", {
- description: "Zoom already exists at this location or not enough space available.",
- });
- return;
- }
+ const actualDuration = timeSelection
+ ? timeSelection.endMs - timeSelection.startMs
+ : Math.min(defaultRegionDurationMs, gapToNext);
- const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
- onZoomAdded({ start: startPos, end: startPos + actualDuration });
- }, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]);
+ const finalStart = timeSelection ? timeSelection.startMs : startPos;
+ const finalEnd = timeSelection ? timeSelection.endMs : startPos + actualDuration;
+
+ onZoomAdded({ start: finalStart, end: finalEnd });
+
+ if (timeSelection) {
+ onTimeSelectionChange?.(null);
+ }
+ }, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs, timeSelection, onTimeSelectionChange]);
const handleSuggestZooms = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
@@ -1174,23 +1300,23 @@ export default function TimelineEditor({
// Always place trim at playhead
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
- // Find the next trim region after the playhead
const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs);
const nextRegion = sorted.find(region => region.startMs > startPos);
const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
- // Check if playhead is inside any trim region
- const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs);
- if (isOverlapping || gapToNext <= 0) {
- toast.error("Cannot place trim here", {
- description: "Trim already exists at this location or not enough space available.",
- });
- return;
- }
+ const actualDuration = timeSelection
+ ? timeSelection.endMs - timeSelection.startMs
+ : Math.min(defaultRegionDurationMs, gapToNext);
- const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
- onTrimAdded({ start: startPos, end: startPos + actualDuration });
- }, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]);
+ const finalStart = timeSelection ? timeSelection.startMs : startPos;
+ const finalEnd = timeSelection ? timeSelection.endMs : startPos + actualDuration;
+
+ onTrimAdded({ start: finalStart, end: finalEnd });
+
+ if (timeSelection) {
+ onTimeSelectionChange?.(null);
+ }
+ }, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs, timeSelection, onTimeSelectionChange]);
const handleAddSpeed = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) {
@@ -1204,23 +1330,23 @@ export default function TimelineEditor({
// Always place speed region at playhead
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
- // Find the next speed region after the playhead
const sorted = [...speedRegions].sort((a, b) => a.startMs - b.startMs);
const nextRegion = sorted.find(region => region.startMs > startPos);
const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
- // Check if playhead is inside any speed region
- const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs);
- if (isOverlapping || gapToNext <= 0) {
- toast.error("Cannot place speed here", {
- description: "Speed region already exists at this location or not enough space available.",
- });
- return;
- }
+ const actualDuration = timeSelection
+ ? timeSelection.endMs - timeSelection.startMs
+ : Math.min(defaultRegionDurationMs, gapToNext);
- const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
- onSpeedAdded({ start: startPos, end: startPos + actualDuration });
- }, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs]);
+ const finalStart = timeSelection ? timeSelection.startMs : startPos;
+ const finalEnd = timeSelection ? timeSelection.endMs : startPos + actualDuration;
+
+ onSpeedAdded({ start: finalStart, end: finalEnd });
+
+ if (timeSelection) {
+ onTimeSelectionChange?.(null);
+ }
+ }, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs, timeSelection, onTimeSelectionChange]);
const handleAddAudio = useCallback(async () => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAudioAdded) {
@@ -1281,11 +1407,16 @@ export default function TimelineEditor({
}
// Multiple annotations can exist at the same timestamp
- const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
- const endPos = Math.min(startPos + defaultDuration, totalMs);
+ const finalStart = timeSelection ? timeSelection.startMs : Math.max(0, Math.min(currentTimeMs, totalMs));
+ const finalEnd = timeSelection ? timeSelection.endMs : Math.min(finalStart + defaultDuration, totalMs);
+
+ onAnnotationAdded({ start: finalStart, end: finalEnd });
+
+ if (timeSelection) {
+ onTimeSelectionChange?.(null);
+ }
+ }, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs, timeSelection, onTimeSelectionChange]);
- onAnnotationAdded({ start: startPos, end: endPos });
- }, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -1304,9 +1435,6 @@ export default function TimelineEditor({
return;
}
- if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) {
- addKeyframe();
- }
if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) {
handleAddZoom();
}
@@ -1736,6 +1864,8 @@ export default function TimelineEditor({
selectAllBlocksActive={selectAllBlocksActive}
onClearBlockSelection={clearSelectedBlocks}
keyframes={keyframes}
+ timeSelection={timeSelection}
+ onTimeSelectionChange={onTimeSelectionChange}
/>
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts
index ac8a7bc7..68b7d310 100644
--- a/src/components/video-editor/types.ts
+++ b/src/components/video-editor/types.ts
@@ -251,6 +251,12 @@ export interface AudioRegion {
volume: number;
}
+
+export interface TimeSelection {
+ startMs: number;
+ endMs: number;
+}
+
export interface CaptionCue {
id: string;
startMs: number;
@@ -283,6 +289,7 @@ export interface AutoCaptionSettings {
textColor: string;
inactiveTextColor: string;
backgroundOpacity: number;
+ generationRange: "full" | "selected";
}
export const DEFAULT_AUTO_CAPTION_SETTINGS: AutoCaptionSettings = {
@@ -298,7 +305,8 @@ export const DEFAULT_AUTO_CAPTION_SETTINGS: AutoCaptionSettings = {
boxRadius: 17.5,
textColor: "#FFFFFF",
inactiveTextColor: "#A3A3A3",
- backgroundOpacity: 0.9,
+ backgroundOpacity: 0.1,
+ generationRange: "full",
};
export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;