mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
feat: improve timeline selection interaction with Shift+drag and closure fixes
This commit is contained in:
Vendored
+3
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user