feat: improve timeline selection interaction with Shift+drag and closure fixes

This commit is contained in:
Mahdy Arief
2026-03-27 21:59:03 +07:00
parent 7e8a40f2fa
commit 01350ad8df
11 changed files with 509 additions and 190 deletions
+3
View File
@@ -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;
};
}
+127 -62
View File
@@ -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,
}
}
+6
View File
@@ -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);
},
@@ -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({
</Button>
</div>
</div>
<div className="flex flex-col gap-3 pt-1">
<div className="flex flex-col gap-1.5 px-1">
<SectionLabel>Generation Range</SectionLabel>
<ToggleGroup
type="single"
value={autoCaptionSettings?.generationRange || "full"}
onValueChange={(val) =>
val &&
onAutoCaptionSettingsChange?.({
...autoCaptionSettings!,
generationRange: val as any,
})
}
className="justify-start gap-1"
>
<ToggleGroupItem
value="full"
className="h-7 cursor-pointer rounded-lg border border-white/5 bg-white/5 px-2.5 text-[10px] data-[state=on]:border-blue-500/50 data-[state=on]:bg-blue-500/20 data-[state=on]:text-blue-400"
>
Full Video
</ToggleGroupItem>
<ToggleGroupItem
value="selected"
className="h-7 cursor-pointer rounded-lg border border-white/5 bg-white/5 px-2.5 text-[10px] data-[state=on]:border-blue-500/50 data-[state=on]:bg-blue-500/20 data-[state=on]:text-blue-400"
>
Selected Timeline {timeSelection ? `(${(timeSelection.startMs / 1000).toFixed(1)}s - ${(timeSelection.endMs / 1000).toFixed(1)}s)` : ""}
</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
<div className="flex flex-col gap-2">
<Button
type="button"
+96 -59
View File
@@ -109,6 +109,7 @@ import {
type ZoomFocus,
type ZoomRegion,
type ZoomTransitionEasing,
type TimeSelection,
} from "./types";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
import {
@@ -380,6 +381,7 @@ export default function VideoEditor() {
const [selectedAudioId, setSelectedAudioId] = useState<string | null>(null);
const [autoCaptions, setAutoCaptions] = useState<CaptionCue[]>([]);
const [selectedCaptionId, setSelectedCaptionId] = useState<string | null>(null);
const [timeSelection, setTimeSelection] = useState<TimeSelection | null>(null);
const [autoCaptionSettings, setAutoCaptionSettings] = useState<AutoCaptionSettings>(() => ({
...DEFAULT_AUTO_CAPTION_SETTINGS,
selectedModel: (initialEditorPreferences.whisperSelectedModel as any) || "small",
@@ -1274,9 +1276,9 @@ export default function VideoEditor() {
() =>
Boolean(
currentProjectPath &&
currentProjectSnapshot &&
lastSavedSnapshot &&
!areDeepEqual(currentProjectSnapshot, lastSavedSnapshot),
currentProjectSnapshot &&
lastSavedSnapshot &&
!areDeepEqual(currentProjectSnapshot, lastSavedSnapshot),
),
[currentProjectPath, currentProjectSnapshot, lastSavedSnapshot],
);
@@ -1442,7 +1444,18 @@ export default function VideoEditor() {
const unlistenProgress = window.electronAPI.onAutoCaptionProgress((payload: { progress: number }) => {
setAutoCaptionProgress(payload.progress);
});
return unlistenProgress;
const unlistenChunk = window.electronAPI.onAutoCaptionChunk(({ cues }) => {
setAutoCaptions((prev) => {
const existingIds = new Set(prev.map((c) => c.id));
const newCues = cues.filter((c) => !existingIds.has(c.id));
if (newCues.length === 0) return prev;
return [...prev, ...newCues].sort((a, b) => a.startMs - b.startMs);
});
});
return () => {
unlistenProgress();
unlistenChunk();
};
}, []);
useEffect(() => {
@@ -1489,7 +1502,7 @@ export default function VideoEditor() {
setWhisperModelDownloadStatus("error");
toast.error(
result.error ||
`Failed to download Whisper ${autoCaptionSettings.selectedModel} model`,
`Failed to download Whisper ${autoCaptionSettings.selectedModel} model`,
);
return;
}
@@ -1516,7 +1529,7 @@ export default function VideoEditor() {
if (!result.success) {
toast.error(
result.error ||
`Failed to delete Whisper ${autoCaptionSettings.selectedModel} model`,
`Failed to delete Whisper ${autoCaptionSettings.selectedModel} model`,
);
return;
}
@@ -1575,31 +1588,51 @@ export default function VideoEditor() {
setIsGeneratingCaptions(true);
setAutoCaptionProgress(0);
const startTimeMs = autoCaptionSettings.generationRange === "selected" && timeSelection
? timeSelection.startMs
: 0;
const rangeDurationMs = autoCaptionSettings.generationRange === "selected" && timeSelection
? timeSelection.endMs - timeSelection.startMs
: duration * 1000;
if (autoCaptionSettings.generationRange === "selected" && !timeSelection) {
toast.error("Please select a range on the timeline first", {
description: "Click and drag or Shift+Click on the timeline to select a range for caption generation.",
});
setIsGeneratingCaptions(false);
return;
}
try {
const result = await window.electronAPI.generateAutoCaptions({
videoPath: sourcePath,
whisperExecutablePath: whisperExecutablePath ?? undefined,
whisperModelPath,
language: autoCaptionSettings.language,
durationMs: rangeDurationMs,
startTimeMs,
});
console.log("[VideoEditor] handleGenerateAutoCaptions: result", result);
if (!result.success || !result.cues) {
if (result.success && result.cues) {
const cuesWithIds = result.cues.map((cue: CaptionCue) => ({
...cue,
id: cue.id || uuidv4(),
}));
// Sort cues by time
cuesWithIds.sort((a, b) => a.startMs - b.startMs);
setAutoCaptions(cuesWithIds);
setAutoCaptionSettings((prev) => ({ ...prev, enabled: true }));
toast.success(`Generated ${cuesWithIds.length} captions`);
} else if (!result.success) {
toast.error(
result.message || getErrorMessage(result.error) || "Failed to generate captions",
);
return;
} else {
toast.error("No captions were generated");
}
const cuesWithIds = result.cues.map((cue: CaptionCue) => ({
...cue,
id: cue.id || uuidv4(),
}));
setAutoCaptions(cuesWithIds);
setAutoCaptionSettings((prev) => ({ ...prev, enabled: true }));
toast.success(result.message || `Generated ${result.cues.length} captions`);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
@@ -1608,13 +1641,15 @@ export default function VideoEditor() {
}
}, [
autoCaptionSettings.language,
autoCaptionSettings.generationRange,
isGeneratingCaptions,
webcam.sourcePath,
syncActiveVideoSource,
videoPath,
videoSourcePath,
whisperExecutablePath,
videoPath,
duration,
whisperModelPath,
timeSelection,
syncActiveVideoSource,
webcam.sourcePath,
]);
const handleClearAutoCaptions = useCallback(() => {
@@ -2001,10 +2036,10 @@ export default function VideoEditor() {
prev.map((region) =>
region.id === id
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
);
@@ -2015,10 +2050,10 @@ export default function VideoEditor() {
prev.map((region) =>
region.id === id
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
);
@@ -2029,9 +2064,9 @@ export default function VideoEditor() {
prev.map((region) =>
region.id === id
? {
...region,
focus: clampFocusToDepth(focus, region.depth),
}
...region,
focus: clampFocusToDepth(focus, region.depth),
}
: region,
),
);
@@ -2044,10 +2079,10 @@ export default function VideoEditor() {
prev.map((region) =>
region.id === selectedZoomId
? {
...region,
depth,
focus: clampFocusToDepth(region.focus, depth),
}
...region,
depth,
focus: clampFocusToDepth(region.focus, depth),
}
: region,
),
);
@@ -2105,10 +2140,10 @@ export default function VideoEditor() {
prev.map((region) =>
region.id === id
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
);
@@ -2156,10 +2191,10 @@ export default function VideoEditor() {
prev.map((region) =>
region.id === id
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
);
@@ -2211,10 +2246,10 @@ export default function VideoEditor() {
prev.map((region) =>
region.id === id
? {
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
...region,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
}
: region,
),
);
@@ -2831,12 +2866,12 @@ export default function VideoEditor() {
gifConfig:
exportFormat === "gif"
? {
frameRate: gifFrameRate,
loop: gifLoop,
sizePreset: gifSizePreset,
width: gifDimensions.width,
height: gifDimensions.height,
}
frameRate: gifFrameRate,
loop: gifLoop,
sizePreset: gifSizePreset,
width: gifDimensions.width,
height: gifDimensions.height,
}
: undefined,
};
@@ -2953,11 +2988,11 @@ export default function VideoEditor() {
? t("editor.exportStatus.saving", "Opening save dialog...")
: isExportFinalizing && typeof exportProgress.renderProgress === "number"
? t("editor.exportStatus.finalizingPercent", "Finalizing {{percent}}%", {
percent: Math.round(exportProgress.renderProgress),
})
percent: Math.round(exportProgress.renderProgress),
})
: t("editor.exportStatus.completePercent", "{{percent}}% complete", {
percent: Math.round(exportProgress.percentage),
})
percent: Math.round(exportProgress.percentage),
})
: t("editor.exportStatus.preparing", "Preparing export...");
const projectBrowser = (
@@ -3431,6 +3466,8 @@ export default function VideoEditor() {
}}
selectedCaptionId={selectedCaptionId}
onSelectCaption={setSelectedCaptionId}
timeSelection={timeSelection}
onTimeSelectionChange={setTimeSelection}
/>
</div>
</Panel>
@@ -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}
/>
</div>
</div>
@@ -498,6 +498,11 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): 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)
@@ -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(
@@ -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;
@@ -23,7 +23,7 @@ const KeyframeMarkers: React.FC<KeyframeMarkersProps> = ({
videoDurationMs,
timelineRef
}) => {
const { sidebarWidth, range, valueToPixels, pixelsToValue } = useTimelineContext();
const { sidebarWidth = 0, range, valueToPixels, pixelsToValue } = useTimelineContext();
const [draggingKeyframeId, setDraggingKeyframeId] = useState<string | null>(null);
useEffect(() => {
@@ -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<HTMLDivElement>;
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 (
<div
className="h-8 bg-[#161619] border-b border-white/10 relative overflow-hidden select-none"
className="h-8 bg-[#161619] border-b border-white/10 relative overflow-hidden select-none cursor-pointer"
style={{
[sideProperty === "right" ? "marginRight" : "marginLeft"]: `${sidebarWidth}px`,
}}
onMouseDown={(e) => {
// 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<HTMLDivElement | null>(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<number | null>(null);
const initialMouseDownPosRef = useRef<{ x: number; y: number } | null>(null);
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
// 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 && (
<div
className="absolute top-0 bottom-0 bg-blue-500/20 border-x border-blue-500/50 z-20 pointer-events-none"
style={{
left: `${sidebarWidth + valueToPixels(Math.max(range.start, timeSelection.startMs) - range.start)}px`,
width: `${valueToPixels(Math.min(range.end, timeSelection.endMs) - Math.max(range.start, timeSelection.startMs))}px`,
display: (timeSelection.endMs < range.start || timeSelection.startMs > range.end) ? 'none' : 'block'
}}
/>
)}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
<PlaybackCursor
@@ -632,7 +753,8 @@ function Timeline({
</Item>
))}
</Row>
<Row id={CAPTION_ROW_ID} isEmpty={captionItems.length === 0} hint="Generated captions will appear here">
{captionItems.map((item) => (
<Item
@@ -698,6 +820,8 @@ export default function TimelineEditor({
onAspectRatioChange,
onOpenCropEditor,
isCropped = false,
timeSelection,
onTimeSelectionChange,
}: TimelineEditorProps) {
const t = useScopedT("settings");
const initialEditorPreferences = useMemo(() => 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}
/>
</TimelineWrapper>
</div>
+9 -1
View File
@@ -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;