added save as
This commit is contained in:
@ -20,9 +20,9 @@ Features are grouped by priority. Check off items as they are implemented.
|
|||||||
|
|
||||||
- [x] [#007] **Speed adjustment (4th zone type)** — add speed zones as the fourth editable timeline/transcript zone type (after cut, mute, gain), allowing slow/fast playback per range or globally. Backend: `ffmpeg -filter:v setpts` + `atempo`. Common use case: slightly speed up boring sections.
|
- [x] [#007] **Speed adjustment (4th zone type)** — add speed zones as the fourth editable timeline/transcript zone type (after cut, mute, gain), allowing slow/fast playback per range or globally. Backend: `ffmpeg -filter:v setpts` + `atempo`. Common use case: slightly speed up boring sections.
|
||||||
|
|
||||||
- [ ] [#008] **Cut preview** — before committing a delete, play what the audio will sound like with that section removed (pre-listen across the edit point). Pure frontend using Web Audio API — splice the AudioBuffer and play the join.
|
- [x] [#008] **Cut preview** — before committing a delete, play what the audio will sound like with that section removed (pre-listen across the edit point). Pure frontend using Web Audio API — splice the AudioBuffer and play the join.
|
||||||
|
|
||||||
- [ ] [#009] **Timeline shows output length** — deleted regions should visually collapse (or show as narrow gaps) so the user sees the *output* duration, not just the source duration.
|
- [x] [#009] **Timeline shows output length** — deleted regions should visually collapse (or show as narrow gaps) so the user sees the *output* duration, not just the source duration.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,7 @@ type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | null;
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const {
|
const {
|
||||||
|
projectFilePath,
|
||||||
videoPath,
|
videoPath,
|
||||||
exportedAudioPath,
|
exportedAudioPath,
|
||||||
words,
|
words,
|
||||||
@ -48,6 +49,7 @@ export default function App() {
|
|||||||
isTranscribing,
|
isTranscribing,
|
||||||
transcriptionStatus,
|
transcriptionStatus,
|
||||||
loadVideo,
|
loadVideo,
|
||||||
|
setProjectFilePath,
|
||||||
setBackendUrl,
|
setBackendUrl,
|
||||||
setTranscription,
|
setTranscription,
|
||||||
setTranscriptionModel,
|
setTranscriptionModel,
|
||||||
@ -179,6 +181,7 @@ export default function App() {
|
|||||||
if (!projectPath) return;
|
if (!projectPath) return;
|
||||||
const content = await window.electronAPI!.readFile(projectPath);
|
const content = await window.electronAPI!.readFile(projectPath);
|
||||||
const data = JSON.parse(content);
|
const data = JSON.parse(content);
|
||||||
|
setProjectFilePath(projectPath);
|
||||||
loadProjectFromData(data);
|
loadProjectFromData(data);
|
||||||
setProjectName(projectPath.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null);
|
setProjectName(projectPath.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -188,14 +191,13 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveProject = async (): Promise<boolean> => {
|
const writeProjectToPath = async (path: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const savePath = await window.electronAPI!.saveProject();
|
|
||||||
if (!savePath) return false;
|
|
||||||
const data = useEditorStore.getState().saveProject();
|
const data = useEditorStore.getState().saveProject();
|
||||||
const path = savePath.endsWith('.aive') ? savePath : `${savePath}.aive`;
|
const resolvedPath = path.endsWith('.aive') ? path : `${path}.aive`;
|
||||||
await window.electronAPI!.writeFile(path, JSON.stringify(data, null, 2));
|
await window.electronAPI!.writeFile(resolvedPath, JSON.stringify(data, null, 2));
|
||||||
setProjectName(path.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null);
|
setProjectFilePath(resolvedPath);
|
||||||
|
setProjectName(resolvedPath.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null);
|
||||||
if (projectSignature) {
|
if (projectSignature) {
|
||||||
setLastSavedSignature(projectSignature);
|
setLastSavedSignature(projectSignature);
|
||||||
}
|
}
|
||||||
@ -207,11 +209,26 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveProjectAs = async (): Promise<boolean> => {
|
||||||
|
const savePath = await window.electronAPI!.saveProject();
|
||||||
|
if (!savePath) return false;
|
||||||
|
return writeProjectToPath(savePath);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveProject = async (): Promise<boolean> => {
|
||||||
|
if (!projectFilePath) {
|
||||||
|
return handleSaveProjectAs();
|
||||||
|
}
|
||||||
|
return writeProjectToPath(projectFilePath);
|
||||||
|
};
|
||||||
|
|
||||||
const handleOpenFile = async () => {
|
const handleOpenFile = async () => {
|
||||||
await runGuarded(async () => {
|
await runGuarded(async () => {
|
||||||
const path = await window.electronAPI!.openFile();
|
const path = await window.electronAPI!.openFile();
|
||||||
if (path) {
|
if (path) {
|
||||||
setLastSavedSignature(null);
|
setLastSavedSignature(null);
|
||||||
|
setProjectFilePath(null);
|
||||||
|
setProjectName(null);
|
||||||
loadVideo(path);
|
loadVideo(path);
|
||||||
await transcribeVideo(path);
|
await transcribeVideo(path);
|
||||||
}
|
}
|
||||||
@ -223,6 +240,8 @@ export default function App() {
|
|||||||
useEditorStore.getState().reset();
|
useEditorStore.getState().reset();
|
||||||
setLastSavedSignature(null);
|
setLastSavedSignature(null);
|
||||||
setActivePanel(null);
|
setActivePanel(null);
|
||||||
|
setProjectFilePath(null);
|
||||||
|
setProjectName(null);
|
||||||
setCutMode(false);
|
setCutMode(false);
|
||||||
setMuteMode(false);
|
setMuteMode(false);
|
||||||
setGainMode(false);
|
setGainMode(false);
|
||||||
@ -458,6 +477,12 @@ export default function App() {
|
|||||||
onClick={handleSaveProject}
|
onClick={handleSaveProject}
|
||||||
disabled={words.length === 0}
|
disabled={words.length === 0}
|
||||||
/>
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={<Save className="w-4 h-4" />}
|
||||||
|
label="Save As"
|
||||||
|
onClick={handleSaveProjectAs}
|
||||||
|
disabled={words.length === 0}
|
||||||
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<FileInput className="w-4 h-4" />}
|
icon={<FileInput className="w-4 h-4" />}
|
||||||
label="Load"
|
label="Load"
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
|
||||||
import { useEditorStore } from '../store/editorStore';
|
import { useEditorStore } from '../store/editorStore';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
const RULER_H = 20; // px reserved at top of canvas for the time ruler
|
const RULER_H = 20; // px reserved at top of canvas for the time ruler
|
||||||
|
const COLLAPSED_CUT_DISPLAY_SECONDS = 0.08;
|
||||||
|
|
||||||
type WaveformData = {
|
type WaveformData = {
|
||||||
samples: Float32Array;
|
samples: Float32Array;
|
||||||
@ -103,6 +104,117 @@ function pickInterval(pxPerSec: number): { major: number; minor: number } {
|
|||||||
return { major, minor };
|
return { major, minor };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TimelineSegment = {
|
||||||
|
kind: 'keep' | 'cut';
|
||||||
|
sourceStart: number;
|
||||||
|
sourceEnd: number;
|
||||||
|
displayStart: number;
|
||||||
|
displayEnd: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mergeCutRanges(ranges: Array<{ start: number; end: number }>, maxDuration: number) {
|
||||||
|
const sorted = ranges
|
||||||
|
.map((range) => ({
|
||||||
|
start: Math.max(0, Math.min(maxDuration, range.start)),
|
||||||
|
end: Math.max(0, Math.min(maxDuration, range.end)),
|
||||||
|
}))
|
||||||
|
.filter((range) => range.end > range.start)
|
||||||
|
.sort((a, b) => a.start - b.start);
|
||||||
|
|
||||||
|
const merged: Array<{ start: number; end: number }> = [];
|
||||||
|
for (const range of sorted) {
|
||||||
|
const last = merged[merged.length - 1];
|
||||||
|
if (!last || range.start > last.end) {
|
||||||
|
merged.push({ ...range });
|
||||||
|
} else {
|
||||||
|
last.end = Math.max(last.end, range.end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTimelineSegments(sourceDuration: number, cutRanges: Array<{ start: number; end: number }>) {
|
||||||
|
if (sourceDuration <= 0) {
|
||||||
|
return { segments: [] as TimelineSegment[], displayDuration: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedCuts = mergeCutRanges(cutRanges, sourceDuration);
|
||||||
|
const segments: TimelineSegment[] = [];
|
||||||
|
let sourceCursor = 0;
|
||||||
|
let displayCursor = 0;
|
||||||
|
|
||||||
|
for (const cutRange of mergedCuts) {
|
||||||
|
if (cutRange.start > sourceCursor) {
|
||||||
|
const keepDuration = cutRange.start - sourceCursor;
|
||||||
|
segments.push({
|
||||||
|
kind: 'keep',
|
||||||
|
sourceStart: sourceCursor,
|
||||||
|
sourceEnd: cutRange.start,
|
||||||
|
displayStart: displayCursor,
|
||||||
|
displayEnd: displayCursor + keepDuration,
|
||||||
|
});
|
||||||
|
displayCursor += keepDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cutDisplayDuration = Math.min(cutRange.end - cutRange.start, COLLAPSED_CUT_DISPLAY_SECONDS);
|
||||||
|
segments.push({
|
||||||
|
kind: 'cut',
|
||||||
|
sourceStart: cutRange.start,
|
||||||
|
sourceEnd: cutRange.end,
|
||||||
|
displayStart: displayCursor,
|
||||||
|
displayEnd: displayCursor + cutDisplayDuration,
|
||||||
|
});
|
||||||
|
displayCursor += cutDisplayDuration;
|
||||||
|
sourceCursor = cutRange.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceCursor < sourceDuration) {
|
||||||
|
const keepDuration = sourceDuration - sourceCursor;
|
||||||
|
segments.push({
|
||||||
|
kind: 'keep',
|
||||||
|
sourceStart: sourceCursor,
|
||||||
|
sourceEnd: sourceDuration,
|
||||||
|
displayStart: displayCursor,
|
||||||
|
displayEnd: displayCursor + keepDuration,
|
||||||
|
});
|
||||||
|
displayCursor += keepDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { segments, displayDuration: displayCursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceToDisplayTime(time: number, segments: TimelineSegment[], sourceDuration: number) {
|
||||||
|
if (segments.length === 0) return Math.max(0, Math.min(sourceDuration, time));
|
||||||
|
const clampedTime = Math.max(0, Math.min(sourceDuration, time));
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (clampedTime <= segment.sourceEnd || segment === segments[segments.length - 1]) {
|
||||||
|
const sourceSpan = Math.max(segment.sourceEnd - segment.sourceStart, 1e-6);
|
||||||
|
const displaySpan = segment.displayEnd - segment.displayStart;
|
||||||
|
const ratio = (clampedTime - segment.sourceStart) / sourceSpan;
|
||||||
|
return segment.displayStart + ratio * displaySpan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments[segments.length - 1].displayEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayToSourceTime(time: number, segments: TimelineSegment[], displayDuration: number, sourceDuration: number) {
|
||||||
|
if (segments.length === 0) return Math.max(0, Math.min(sourceDuration, time));
|
||||||
|
const clampedTime = Math.max(0, Math.min(displayDuration, time));
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (clampedTime <= segment.displayEnd || segment === segments[segments.length - 1]) {
|
||||||
|
const displaySpan = Math.max(segment.displayEnd - segment.displayStart, 1e-6);
|
||||||
|
const sourceSpan = segment.sourceEnd - segment.sourceStart;
|
||||||
|
const ratio = (clampedTime - segment.displayStart) / displaySpan;
|
||||||
|
return segment.sourceStart + ratio * sourceSpan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceDuration;
|
||||||
|
}
|
||||||
|
|
||||||
export default function WaveformTimeline({
|
export default function WaveformTimeline({
|
||||||
cutMode,
|
cutMode,
|
||||||
muteMode,
|
muteMode,
|
||||||
@ -165,6 +277,12 @@ export default function WaveformTimeline({
|
|||||||
const [showGainZones, setShowGainZones] = useState(true);
|
const [showGainZones, setShowGainZones] = useState(true);
|
||||||
const [showSpeedZones, setShowSpeedZones] = useState(true);
|
const [showSpeedZones, setShowSpeedZones] = useState(true);
|
||||||
|
|
||||||
|
const sourceDuration = duration || waveformDataRef.current?.duration || 0;
|
||||||
|
const { segments: timelineSegments, displayDuration } = useMemo(
|
||||||
|
() => buildTimelineSegments(sourceDuration, cutRanges),
|
||||||
|
[sourceDuration, cutRanges],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!videoUrl || !videoPath) return;
|
if (!videoUrl || !videoPath) return;
|
||||||
setAudioError(null);
|
setAudioError(null);
|
||||||
@ -261,9 +379,10 @@ export default function WaveformTimeline({
|
|||||||
const width = rect.width;
|
const width = rect.width;
|
||||||
const height = rect.height;
|
const height = rect.height;
|
||||||
const dur = waveformData.duration;
|
const dur = waveformData.duration;
|
||||||
|
const timelineDur = displayDuration || dur;
|
||||||
const zoom = zoomRef.current;
|
const zoom = zoomRef.current;
|
||||||
const scroll = scrollSecsRef.current;
|
const scroll = scrollSecsRef.current;
|
||||||
const pxPerSec = (width * zoom) / dur;
|
const pxPerSec = (width * zoom) / timelineDur;
|
||||||
const sampleRate = waveformData.sampleRate;
|
const sampleRate = waveformData.sampleRate;
|
||||||
const channelData = waveformData.samples;
|
const channelData = waveformData.samples;
|
||||||
|
|
||||||
@ -323,8 +442,8 @@ export default function WaveformTimeline({
|
|||||||
|
|
||||||
// Draw cut ranges (red overlays)
|
// Draw cut ranges (red overlays)
|
||||||
for (const range of showCutZones ? cutRanges : []) {
|
for (const range of showCutZones ? cutRanges : []) {
|
||||||
const x1 = (range.start - scroll) * pxPerSec;
|
const x1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const x2 = (range.end - scroll) * pxPerSec;
|
const x2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id;
|
const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id;
|
||||||
|
|
||||||
ctx.fillStyle = isSelected ? 'rgba(239, 68, 68, 0.5)' : 'rgba(239, 68, 68, 0.3)';
|
ctx.fillStyle = isSelected ? 'rgba(239, 68, 68, 0.5)' : 'rgba(239, 68, 68, 0.3)';
|
||||||
@ -348,8 +467,8 @@ export default function WaveformTimeline({
|
|||||||
|
|
||||||
// Draw mute ranges (blue overlays)
|
// Draw mute ranges (blue overlays)
|
||||||
for (const range of showMuteZones ? muteRanges : []) {
|
for (const range of showMuteZones ? muteRanges : []) {
|
||||||
const x1 = (range.start - scroll) * pxPerSec;
|
const x1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const x2 = (range.end - scroll) * pxPerSec;
|
const x2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id;
|
const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id;
|
||||||
|
|
||||||
ctx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.5)' : 'rgba(59, 130, 246, 0.3)';
|
ctx.fillStyle = isSelected ? 'rgba(59, 130, 246, 0.5)' : 'rgba(59, 130, 246, 0.3)';
|
||||||
@ -373,8 +492,8 @@ export default function WaveformTimeline({
|
|||||||
|
|
||||||
// Draw gain ranges (amber overlays)
|
// Draw gain ranges (amber overlays)
|
||||||
for (const range of showGainZones ? gainRanges : []) {
|
for (const range of showGainZones ? gainRanges : []) {
|
||||||
const x1 = (range.start - scroll) * pxPerSec;
|
const x1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const x2 = (range.end - scroll) * pxPerSec;
|
const x2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const isSelected = selectedZone?.type === 'gain' && selectedZone.id === range.id;
|
const isSelected = selectedZone?.type === 'gain' && selectedZone.id === range.id;
|
||||||
|
|
||||||
ctx.fillStyle = isSelected ? 'rgba(245, 158, 11, 0.55)' : 'rgba(245, 158, 11, 0.35)';
|
ctx.fillStyle = isSelected ? 'rgba(245, 158, 11, 0.55)' : 'rgba(245, 158, 11, 0.35)';
|
||||||
@ -397,8 +516,8 @@ export default function WaveformTimeline({
|
|||||||
|
|
||||||
// Draw speed ranges (emerald overlays)
|
// Draw speed ranges (emerald overlays)
|
||||||
for (const range of showSpeedZones ? speedRanges : []) {
|
for (const range of showSpeedZones ? speedRanges : []) {
|
||||||
const x1 = (range.start - scroll) * pxPerSec;
|
const x1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const x2 = (range.end - scroll) * pxPerSec;
|
const x2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const isSelected = selectedZone?.type === 'speed' && selectedZone.id === range.id;
|
const isSelected = selectedZone?.type === 'speed' && selectedZone.id === range.id;
|
||||||
|
|
||||||
ctx.fillStyle = isSelected ? 'rgba(16, 185, 129, 0.55)' : 'rgba(16, 185, 129, 0.35)';
|
ctx.fillStyle = isSelected ? 'rgba(16, 185, 129, 0.55)' : 'rgba(16, 185, 129, 0.35)';
|
||||||
@ -432,8 +551,8 @@ export default function WaveformTimeline({
|
|||||||
|
|
||||||
// Draw selection overlay (when in zone mode)
|
// Draw selection overlay (when in zone mode)
|
||||||
if ((cutMode || muteMode || gainMode || speedMode) && selectionStart !== null && selectionEnd !== null) {
|
if ((cutMode || muteMode || gainMode || speedMode) && selectionStart !== null && selectionEnd !== null) {
|
||||||
const x1 = (Math.min(selectionStart, selectionEnd) - scroll) * pxPerSec;
|
const x1 = (sourceToDisplayTime(Math.min(selectionStart, selectionEnd), timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const x2 = (Math.max(selectionStart, selectionEnd) - scroll) * pxPerSec;
|
const x2 = (sourceToDisplayTime(Math.max(selectionStart, selectionEnd), timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const fillColor = cutMode
|
const fillColor = cutMode
|
||||||
? 'rgba(239, 68, 68, 0.5)'
|
? 'rgba(239, 68, 68, 0.5)'
|
||||||
: muteMode
|
: muteMode
|
||||||
@ -457,8 +576,8 @@ export default function WaveformTimeline({
|
|||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
const tStart = scroll + x / pxPerSec;
|
const tStart = displayToSourceTime(scroll + x / pxPerSec, timelineSegments, timelineDur, dur);
|
||||||
const tEnd = scroll + (x + 1) / pxPerSec;
|
const tEnd = displayToSourceTime(scroll + (x + 1) / pxPerSec, timelineSegments, timelineDur, dur);
|
||||||
const sStart = Math.floor(tStart * sampleRate);
|
const sStart = Math.floor(tStart * sampleRate);
|
||||||
const sEnd = Math.min(Math.ceil(tEnd * sampleRate), channelData.length);
|
const sEnd = Math.min(Math.ceil(tEnd * sampleRate), channelData.length);
|
||||||
if (sStart >= channelData.length) break;
|
if (sStart >= channelData.length) break;
|
||||||
@ -486,10 +605,12 @@ export default function WaveformTimeline({
|
|||||||
gainMode,
|
gainMode,
|
||||||
speedMode,
|
speedMode,
|
||||||
selectedZone,
|
selectedZone,
|
||||||
|
displayDuration,
|
||||||
showCutZones,
|
showCutZones,
|
||||||
showMuteZones,
|
showMuteZones,
|
||||||
showGainZones,
|
showGainZones,
|
||||||
showSpeedZones,
|
showSpeedZones,
|
||||||
|
timelineSegments,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Keep the ref in sync with the latest drawStaticWaveform closure
|
// Keep the ref in sync with the latest drawStaticWaveform closure
|
||||||
@ -515,6 +636,7 @@ export default function WaveformTimeline({
|
|||||||
|
|
||||||
const video = document.querySelector('video') as HTMLVideoElement | null;
|
const video = document.querySelector('video') as HTMLVideoElement | null;
|
||||||
const dur = waveformDataRef.current?.duration ?? 0;
|
const dur = waveformDataRef.current?.duration ?? 0;
|
||||||
|
const timelineDur = displayDuration || dur;
|
||||||
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const rect = headCanvas.getBoundingClientRect();
|
const rect = headCanvas.getBoundingClientRect();
|
||||||
@ -529,17 +651,18 @@ export default function WaveformTimeline({
|
|||||||
ctx.clearRect(0, 0, width, height);
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
if (dur > 0 && video) {
|
if (dur > 0 && video) {
|
||||||
const pxPerSec = (width * zoomRef.current) / dur;
|
const pxPerSec = (width * zoomRef.current) / timelineDur;
|
||||||
let px = (video.currentTime - scrollSecsRef.current) * pxPerSec;
|
const displayTime = sourceToDisplayTime(video.currentTime, timelineSegments, dur);
|
||||||
|
let px = (displayTime - scrollSecsRef.current) * pxPerSec;
|
||||||
|
|
||||||
// If the playhead is off-screen (e.g. after a seek from the transcript),
|
// If the playhead is off-screen (e.g. after a seek from the transcript),
|
||||||
// scroll so it's centered and redraw the static waveform layer.
|
// scroll so it's centered and redraw the static waveform layer.
|
||||||
if (px < 0 || px > width) {
|
if (px < 0 || px > width) {
|
||||||
const visibleSecs = width / pxPerSec;
|
const visibleSecs = width / pxPerSec;
|
||||||
const maxScroll = Math.max(0, dur - visibleSecs);
|
const maxScroll = Math.max(0, timelineDur - visibleSecs);
|
||||||
scrollSecsRef.current = Math.max(0, Math.min(maxScroll, video.currentTime - visibleSecs / 2));
|
scrollSecsRef.current = Math.max(0, Math.min(maxScroll, displayTime - visibleSecs / 2));
|
||||||
drawStaticWaveformRef.current();
|
drawStaticWaveformRef.current();
|
||||||
px = (video.currentTime - scrollSecsRef.current) * pxPerSec;
|
px = (displayTime - scrollSecsRef.current) * pxPerSec;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@ -555,7 +678,7 @@ export default function WaveformTimeline({
|
|||||||
|
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
return () => cancelAnimationFrame(rafRef.current);
|
return () => cancelAnimationFrame(rafRef.current);
|
||||||
}, [videoUrl]);
|
}, [videoUrl, displayDuration, timelineSegments]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
@ -572,28 +695,29 @@ export default function WaveformTimeline({
|
|||||||
const dur = waveformDataRef.current?.duration;
|
const dur = waveformDataRef.current?.duration;
|
||||||
if (!dur) return;
|
if (!dur) return;
|
||||||
const width = canvas.getBoundingClientRect().width;
|
const width = canvas.getBoundingClientRect().width;
|
||||||
|
const timelineDur = displayDuration || dur;
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
// Zoom around the cursor position
|
// Zoom around the cursor position
|
||||||
const mouseX = e.clientX - canvas.getBoundingClientRect().left;
|
const mouseX = e.clientX - canvas.getBoundingClientRect().left;
|
||||||
const pxPerSecBefore = (width * zoomRef.current) / dur;
|
const pxPerSecBefore = (width * zoomRef.current) / timelineDur;
|
||||||
const timeCursor = scrollSecsRef.current + mouseX / pxPerSecBefore;
|
const timeCursor = scrollSecsRef.current + mouseX / pxPerSecBefore;
|
||||||
const factor = e.deltaY < 0 ? 1.25 : 1 / 1.25;
|
const factor = e.deltaY < 0 ? 1.25 : 1 / 1.25;
|
||||||
zoomRef.current = Math.max(1, Math.min(100, zoomRef.current * factor));
|
zoomRef.current = Math.max(1, Math.min(100, zoomRef.current * factor));
|
||||||
const pxPerSecAfter = (width * zoomRef.current) / dur;
|
const pxPerSecAfter = (width * zoomRef.current) / timelineDur;
|
||||||
scrollSecsRef.current = timeCursor - mouseX / pxPerSecAfter;
|
scrollSecsRef.current = timeCursor - mouseX / pxPerSecAfter;
|
||||||
} else {
|
} else {
|
||||||
// Scroll horizontally
|
// Scroll horizontally
|
||||||
const pxPerSec = (width * zoomRef.current) / dur;
|
const pxPerSec = (width * zoomRef.current) / timelineDur;
|
||||||
scrollSecsRef.current += (e.deltaY || e.deltaX) / pxPerSec * 1.5;
|
scrollSecsRef.current += (e.deltaY || e.deltaX) / pxPerSec * 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp scroll
|
// Clamp scroll
|
||||||
const pxPerSec = (width * zoomRef.current) / dur;
|
const pxPerSec = (width * zoomRef.current) / timelineDur;
|
||||||
const maxScroll = Math.max(0, dur - width / pxPerSec);
|
const maxScroll = Math.max(0, timelineDur - width / pxPerSec);
|
||||||
scrollSecsRef.current = Math.max(0, Math.min(scrollSecsRef.current, maxScroll));
|
scrollSecsRef.current = Math.max(0, Math.min(scrollSecsRef.current, maxScroll));
|
||||||
drawStaticWaveform();
|
drawStaticWaveform();
|
||||||
}, [drawStaticWaveform]);
|
}, [displayDuration, drawStaticWaveform]);
|
||||||
|
|
||||||
const seekToClientX = useCallback((clientX: number) => {
|
const seekToClientX = useCallback((clientX: number) => {
|
||||||
const canvas = headCanvasRef.current;
|
const canvas = headCanvasRef.current;
|
||||||
@ -601,12 +725,14 @@ export default function WaveformTimeline({
|
|||||||
if (!canvas || !dur) return;
|
if (!canvas || !dur) return;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const x = clientX - rect.left;
|
const x = clientX - rect.left;
|
||||||
const pxPerSec = (rect.width * zoomRef.current) / dur;
|
const timelineDur = displayDuration || dur;
|
||||||
const newTime = Math.max(0, Math.min(dur, scrollSecsRef.current + x / pxPerSec));
|
const pxPerSec = (rect.width * zoomRef.current) / timelineDur;
|
||||||
|
const displayTime = Math.max(0, Math.min(timelineDur, scrollSecsRef.current + x / pxPerSec));
|
||||||
|
const newTime = displayToSourceTime(displayTime, timelineSegments, timelineDur, dur);
|
||||||
setCurrentTime(newTime);
|
setCurrentTime(newTime);
|
||||||
const video = document.querySelector('video') as HTMLVideoElement | null;
|
const video = document.querySelector('video') as HTMLVideoElement | null;
|
||||||
if (video) video.currentTime = newTime;
|
if (video) video.currentTime = newTime;
|
||||||
}, [setCurrentTime]);
|
}, [displayDuration, setCurrentTime, timelineSegments]);
|
||||||
|
|
||||||
const clientXToTime = useCallback((clientX: number): number => {
|
const clientXToTime = useCallback((clientX: number): number => {
|
||||||
const canvas = headCanvasRef.current;
|
const canvas = headCanvasRef.current;
|
||||||
@ -614,9 +740,11 @@ export default function WaveformTimeline({
|
|||||||
if (!canvas || !dur) return 0;
|
if (!canvas || !dur) return 0;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const x = clientX - rect.left;
|
const x = clientX - rect.left;
|
||||||
const pxPerSec = (rect.width * zoomRef.current) / dur;
|
const timelineDur = displayDuration || dur;
|
||||||
return Math.max(0, Math.min(dur, scrollSecsRef.current + x / pxPerSec));
|
const pxPerSec = (rect.width * zoomRef.current) / timelineDur;
|
||||||
}, []);
|
const displayTime = Math.max(0, Math.min(timelineDur, scrollSecsRef.current + x / pxPerSec));
|
||||||
|
return displayToSourceTime(displayTime, timelineSegments, timelineDur, dur);
|
||||||
|
}, [displayDuration, timelineSegments]);
|
||||||
|
|
||||||
const getZoneAtPosition = useCallback((clientX: number, clientY: number, forHover: boolean = false) => {
|
const getZoneAtPosition = useCallback((clientX: number, clientY: number, forHover: boolean = false) => {
|
||||||
const dur = waveformDataRef.current?.duration;
|
const dur = waveformDataRef.current?.duration;
|
||||||
@ -626,7 +754,8 @@ export default function WaveformTimeline({
|
|||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const x = clientX - rect.left;
|
const x = clientX - rect.left;
|
||||||
const y = clientY - rect.top;
|
const y = clientY - rect.top;
|
||||||
const pxPerSec = (rect.width * zoomRef.current) / dur;
|
const timelineDur = displayDuration || dur;
|
||||||
|
const pxPerSec = (rect.width * zoomRef.current) / timelineDur;
|
||||||
const scroll = scrollSecsRef.current;
|
const scroll = scrollSecsRef.current;
|
||||||
const waveTop = RULER_H + 1;
|
const waveTop = RULER_H + 1;
|
||||||
const waveH = canvas.height - waveTop;
|
const waveH = canvas.height - waveTop;
|
||||||
@ -638,8 +767,8 @@ export default function WaveformTimeline({
|
|||||||
|
|
||||||
// Check cut ranges
|
// Check cut ranges
|
||||||
for (const range of showCutZones ? cutRanges : []) {
|
for (const range of showCutZones ? cutRanges : []) {
|
||||||
const rangeX1 = (range.start - scroll) * pxPerSec;
|
const rangeX1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const rangeX2 = (range.end - scroll) * pxPerSec;
|
const rangeX2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id;
|
const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id;
|
||||||
|
|
||||||
if (forHover && isSelected) {
|
if (forHover && isSelected) {
|
||||||
@ -680,8 +809,8 @@ export default function WaveformTimeline({
|
|||||||
|
|
||||||
// Check mute ranges
|
// Check mute ranges
|
||||||
for (const range of showMuteZones ? muteRanges : []) {
|
for (const range of showMuteZones ? muteRanges : []) {
|
||||||
const rangeX1 = (range.start - scroll) * pxPerSec;
|
const rangeX1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const rangeX2 = (range.end - scroll) * pxPerSec;
|
const rangeX2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id;
|
const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id;
|
||||||
|
|
||||||
if (forHover && isSelected) {
|
if (forHover && isSelected) {
|
||||||
@ -722,8 +851,8 @@ export default function WaveformTimeline({
|
|||||||
|
|
||||||
// Check gain ranges
|
// Check gain ranges
|
||||||
for (const range of showGainZones ? gainRanges : []) {
|
for (const range of showGainZones ? gainRanges : []) {
|
||||||
const rangeX1 = (range.start - scroll) * pxPerSec;
|
const rangeX1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const rangeX2 = (range.end - scroll) * pxPerSec;
|
const rangeX2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const isSelected = selectedZone?.type === 'gain' && selectedZone.id === range.id;
|
const isSelected = selectedZone?.type === 'gain' && selectedZone.id === range.id;
|
||||||
|
|
||||||
if (forHover && isSelected) {
|
if (forHover && isSelected) {
|
||||||
@ -757,8 +886,8 @@ export default function WaveformTimeline({
|
|||||||
|
|
||||||
// Check speed ranges
|
// Check speed ranges
|
||||||
for (const range of showSpeedZones ? speedRanges : []) {
|
for (const range of showSpeedZones ? speedRanges : []) {
|
||||||
const rangeX1 = (range.start - scroll) * pxPerSec;
|
const rangeX1 = (sourceToDisplayTime(range.start, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const rangeX2 = (range.end - scroll) * pxPerSec;
|
const rangeX2 = (sourceToDisplayTime(range.end, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
const isSelected = selectedZone?.type === 'speed' && selectedZone.id === range.id;
|
const isSelected = selectedZone?.type === 'speed' && selectedZone.id === range.id;
|
||||||
|
|
||||||
if (forHover && isSelected) {
|
if (forHover && isSelected) {
|
||||||
@ -791,7 +920,7 @@ export default function WaveformTimeline({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [cutRanges, muteRanges, gainRanges, speedRanges, selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones]);
|
}, [cutRanges, muteRanges, gainRanges, speedRanges, selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones, displayDuration, timelineSegments]);
|
||||||
|
|
||||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
if (isDragging) return; // Don't change cursor while dragging
|
if (isDragging) return; // Don't change cursor while dragging
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useEditorStore } from '../store/editorStore';
|
import { useEditorStore } from '../store/editorStore';
|
||||||
import { Trash2, Scissors, Volume2, SlidersHorizontal, Gauge, Play } from 'lucide-react';
|
import { Trash2, Scissors, Volume2, SlidersHorizontal, Gauge, Play } from 'lucide-react';
|
||||||
|
|
||||||
export default function ZoneEditor() {
|
export default function ZoneEditor() {
|
||||||
const [viewMode, setViewMode] = useState<'all' | 'cut' | 'mute' | 'gain' | 'speed'>('all');
|
const [viewMode, setViewMode] = useState<'all' | 'cut' | 'mute' | 'gain' | 'speed'>('all');
|
||||||
|
const [focusedZone, setFocusedZone] = useState<{ type: 'cut' | 'mute' | 'gain' | 'speed'; id: string } | null>(null);
|
||||||
const previewFrameRef = useRef<number | null>(null);
|
const previewFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -68,7 +69,10 @@ export default function ZoneEditor() {
|
|||||||
|
|
||||||
const renderPreviewButton = (start: number, end: number, accentClass: string) => (
|
const renderPreviewButton = (start: number, end: number, accentClass: string) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => previewZone(start, end)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
previewZone(start, end);
|
||||||
|
}}
|
||||||
className={`p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity ${accentClass}`}
|
className={`p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity ${accentClass}`}
|
||||||
title={`Play ${zonePreviewPaddingSeconds.toFixed(2)}s before and after zone`}
|
title={`Play ${zonePreviewPaddingSeconds.toFixed(2)}s before and after zone`}
|
||||||
>
|
>
|
||||||
@ -91,6 +95,53 @@ export default function ZoneEditor() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const activeFocusedZone = useMemo(() => {
|
||||||
|
if (!focusedZone) return null;
|
||||||
|
const exists = focusedZone.type === 'cut'
|
||||||
|
? cutRanges.some((range) => range.id === focusedZone.id)
|
||||||
|
: focusedZone.type === 'mute'
|
||||||
|
? muteRanges.some((range) => range.id === focusedZone.id)
|
||||||
|
: focusedZone.type === 'gain'
|
||||||
|
? gainRanges.some((range) => range.id === focusedZone.id)
|
||||||
|
: speedRanges.some((range) => range.id === focusedZone.id);
|
||||||
|
return exists ? focusedZone : null;
|
||||||
|
}, [cutRanges, focusedZone, gainRanges, muteRanges, speedRanges]);
|
||||||
|
|
||||||
|
const isZoneFocused = useCallback(
|
||||||
|
(type: 'cut' | 'mute' | 'gain' | 'speed', id: string) => activeFocusedZone?.type === type && activeFocusedZone.id === id,
|
||||||
|
[activeFocusedZone],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeZone = useCallback((type: 'cut' | 'mute' | 'gain' | 'speed', id: string) => {
|
||||||
|
if (type === 'cut') removeCutRange(id);
|
||||||
|
else if (type === 'mute') removeMuteRange(id);
|
||||||
|
else if (type === 'gain') removeGainRange(id);
|
||||||
|
else removeSpeedRange(id);
|
||||||
|
setFocusedZone((current) => (current?.type === type && current.id === id ? null : current));
|
||||||
|
}, [removeCutRange, removeGainRange, removeMuteRange, removeSpeedRange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setFocusedZone(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((e.key === 'Delete' || e.key === 'Backspace') && activeFocusedZone) {
|
||||||
|
e.preventDefault();
|
||||||
|
removeZone(activeFocusedZone.type, activeFocusedZone.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||||
|
}, [activeFocusedZone, removeZone]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -200,7 +251,8 @@ export default function ZoneEditor() {
|
|||||||
{cutRanges.map((range) => (
|
{cutRanges.map((range) => (
|
||||||
<div
|
<div
|
||||||
key={range.id}
|
key={range.id}
|
||||||
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group ${getZoneTypeColor('cut')}`}
|
onClick={() => setFocusedZone({ type: 'cut', id: range.id })}
|
||||||
|
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('cut')} ${isZoneFocused('cut', range.id) ? 'ring-1 ring-red-400 border-red-400/80 bg-red-500/12' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium truncate">
|
<div className="font-medium truncate">
|
||||||
@ -210,7 +262,10 @@ export default function ZoneEditor() {
|
|||||||
</div>
|
</div>
|
||||||
{renderPreviewButton(range.start, range.end, 'hover:bg-red-500/20 text-red-500/70 hover:text-red-500')}
|
{renderPreviewButton(range.start, range.end, 'hover:bg-red-500/20 text-red-500/70 hover:text-red-500')}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeCutRange(range.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeZone('cut', range.id);
|
||||||
|
}}
|
||||||
className="p-1 rounded hover:bg-red-500/20 text-red-500/70 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="p-1 rounded hover:bg-red-500/20 text-red-500/70 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
title="Delete cut zone"
|
title="Delete cut zone"
|
||||||
>
|
>
|
||||||
@ -233,7 +288,8 @@ export default function ZoneEditor() {
|
|||||||
{muteRanges.map((range) => (
|
{muteRanges.map((range) => (
|
||||||
<div
|
<div
|
||||||
key={range.id}
|
key={range.id}
|
||||||
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group ${getZoneTypeColor('mute')}`}
|
onClick={() => setFocusedZone({ type: 'mute', id: range.id })}
|
||||||
|
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('mute')} ${isZoneFocused('mute', range.id) ? 'ring-1 ring-orange-400 border-orange-400/80 bg-orange-500/12' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium truncate">
|
<div className="font-medium truncate">
|
||||||
@ -243,7 +299,10 @@ export default function ZoneEditor() {
|
|||||||
</div>
|
</div>
|
||||||
{renderPreviewButton(range.start, range.end, 'hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500')}
|
{renderPreviewButton(range.start, range.end, 'hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500')}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeMuteRange(range.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeZone('mute', range.id);
|
||||||
|
}}
|
||||||
className="p-1 rounded hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="p-1 rounded hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
title="Delete mute zone"
|
title="Delete mute zone"
|
||||||
>
|
>
|
||||||
@ -293,7 +352,8 @@ export default function ZoneEditor() {
|
|||||||
{gainRanges.map((range) => (
|
{gainRanges.map((range) => (
|
||||||
<div
|
<div
|
||||||
key={range.id}
|
key={range.id}
|
||||||
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group ${getZoneTypeColor('gain')}`}
|
onClick={() => setFocusedZone({ type: 'gain', id: range.id })}
|
||||||
|
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('gain')} ${isZoneFocused('gain', range.id) ? 'ring-1 ring-amber-400 border-amber-400/80 bg-amber-500/12' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium truncate">
|
<div className="font-medium truncate">
|
||||||
@ -309,13 +369,17 @@ export default function ZoneEditor() {
|
|||||||
max={24}
|
max={24}
|
||||||
step={0.5}
|
step={0.5}
|
||||||
value={range.gainDb}
|
value={range.gainDb}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => updateGainRange(range.id, Number(e.target.value) || 0)}
|
onChange={(e) => updateGainRange(range.id, Number(e.target.value) || 0)}
|
||||||
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||||
title="Gain dB"
|
title="Gain dB"
|
||||||
/>
|
/>
|
||||||
{renderPreviewButton(range.start, range.end, 'hover:bg-amber-500/20 text-amber-500/70 hover:text-amber-500')}
|
{renderPreviewButton(range.start, range.end, 'hover:bg-amber-500/20 text-amber-500/70 hover:text-amber-500')}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeGainRange(range.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeZone('gain', range.id);
|
||||||
|
}}
|
||||||
className="p-1 rounded hover:bg-amber-500/20 text-amber-500/70 hover:text-amber-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="p-1 rounded hover:bg-amber-500/20 text-amber-500/70 hover:text-amber-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
title="Delete gain zone"
|
title="Delete gain zone"
|
||||||
>
|
>
|
||||||
@ -338,7 +402,8 @@ export default function ZoneEditor() {
|
|||||||
{speedRanges.map((range) => (
|
{speedRanges.map((range) => (
|
||||||
<div
|
<div
|
||||||
key={range.id}
|
key={range.id}
|
||||||
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group ${getZoneTypeColor('speed')}`}
|
onClick={() => setFocusedZone({ type: 'speed', id: range.id })}
|
||||||
|
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('speed')} ${isZoneFocused('speed', range.id) ? 'ring-1 ring-emerald-400 border-emerald-400/80 bg-emerald-500/12' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium truncate">
|
<div className="font-medium truncate">
|
||||||
@ -354,13 +419,17 @@ export default function ZoneEditor() {
|
|||||||
max={4}
|
max={4}
|
||||||
step={0.05}
|
step={0.05}
|
||||||
value={range.speed}
|
value={range.speed}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => updateSpeedRange(range.id, Number(e.target.value) || 1)}
|
onChange={(e) => updateSpeedRange(range.id, Number(e.target.value) || 1)}
|
||||||
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||||
title="Speed multiplier"
|
title="Speed multiplier"
|
||||||
/>
|
/>
|
||||||
{renderPreviewButton(range.start, range.end, 'hover:bg-emerald-500/20 text-emerald-500/70 hover:text-emerald-500')}
|
{renderPreviewButton(range.start, range.end, 'hover:bg-emerald-500/20 text-emerald-500/70 hover:text-emerald-500')}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeSpeedRange(range.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeZone('speed', range.id);
|
||||||
|
}}
|
||||||
className="p-1 rounded hover:bg-emerald-500/20 text-emerald-500/70 hover:text-emerald-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="p-1 rounded hover:bg-emerald-500/20 text-emerald-500/70 hover:text-emerald-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
title="Delete speed zone"
|
title="Delete speed zone"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -120,7 +120,9 @@ export function useKeyboardShortcuts() {
|
|||||||
// --- Ctrl+S: save project ---
|
// --- Ctrl+S: save project ---
|
||||||
case e.key === 's' && (e.ctrlKey || e.metaKey): {
|
case e.key === 's' && (e.ctrlKey || e.metaKey): {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
saveProject();
|
const saveBtn = document.querySelector('[title="Save"]') as HTMLButtonElement | null;
|
||||||
|
if (saveBtn) saveBtn.click();
|
||||||
|
else saveProject();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,33 +157,32 @@ async function saveProject() {
|
|||||||
if (!state.videoPath || state.words.length === 0) return;
|
if (!state.videoPath || state.words.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projectData = {
|
const projectData = state.saveProject();
|
||||||
version: 1,
|
let outputPath = state.projectFilePath;
|
||||||
videoPath: state.videoPath,
|
|
||||||
words: state.words,
|
|
||||||
segments: state.segments,
|
|
||||||
language: state.language,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
modifiedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const outputPath = await window.electronAPI?.saveFile({
|
if (!outputPath) {
|
||||||
defaultPath: state.videoPath.replace(/\.[^.]+$/, '.aive'),
|
outputPath = await window.electronAPI?.saveFile({
|
||||||
filters: [{ name: 'TalkEdit Project', extensions: ['aive'] }],
|
defaultPath: state.videoPath.replace(/\.[^.]+$/, '.aive'),
|
||||||
});
|
filters: [{ name: 'TalkEdit Project', extensions: ['aive'] }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (outputPath) {
|
if (!outputPath) return;
|
||||||
if (window.electronAPI?.writeFile) {
|
|
||||||
await window.electronAPI.writeFile(outputPath, JSON.stringify(projectData, null, 2));
|
const resolvedPath = outputPath.endsWith('.aive') ? outputPath : `${outputPath}.aive`;
|
||||||
} else {
|
|
||||||
const blob = new Blob([JSON.stringify(projectData, null, 2)], { type: 'application/json' });
|
if (window.electronAPI?.writeFile) {
|
||||||
const url = URL.createObjectURL(blob);
|
await window.electronAPI.writeFile(resolvedPath, JSON.stringify(projectData, null, 2));
|
||||||
const a = document.createElement('a');
|
useEditorStore.getState().setProjectFilePath(resolvedPath);
|
||||||
a.href = url;
|
} else {
|
||||||
a.download = outputPath.split(/[\\/]/).pop() || 'project.aive';
|
const blob = new Blob([JSON.stringify(projectData, null, 2)], { type: 'application/json' });
|
||||||
a.click();
|
const url = URL.createObjectURL(blob);
|
||||||
URL.revokeObjectURL(url);
|
const a = document.createElement('a');
|
||||||
}
|
a.href = url;
|
||||||
|
a.download = resolvedPath.split(/[\\/]/).pop() || 'project.aive';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
useEditorStore.getState().setProjectFilePath(resolvedPath);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save project:', err);
|
console.error('Failed to save project:', err);
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import type {
|
|||||||
} from '../types/project';
|
} from '../types/project';
|
||||||
|
|
||||||
interface EditorState {
|
interface EditorState {
|
||||||
|
projectFilePath: string | null;
|
||||||
videoPath: string | null;
|
videoPath: string | null;
|
||||||
videoUrl: string | null;
|
videoUrl: string | null;
|
||||||
exportedAudioPath: string | null; // path to modified audio from a previous export
|
exportedAudioPath: string | null; // path to modified audio from a previous export
|
||||||
@ -48,6 +49,7 @@ interface EditorState {
|
|||||||
|
|
||||||
interface EditorActions {
|
interface EditorActions {
|
||||||
setBackendUrl: (url: string) => void;
|
setBackendUrl: (url: string) => void;
|
||||||
|
setProjectFilePath: (path: string | null) => void;
|
||||||
loadVideo: (path: string) => void;
|
loadVideo: (path: string) => void;
|
||||||
setExportedAudioPath: (path: string | null) => void;
|
setExportedAudioPath: (path: string | null) => void;
|
||||||
setTranscriptionModel: (model: string | null) => void;
|
setTranscriptionModel: (model: string | null) => void;
|
||||||
@ -101,6 +103,7 @@ function getStoredZonePreviewPaddingSeconds() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialState: EditorState = {
|
const initialState: EditorState = {
|
||||||
|
projectFilePath: null,
|
||||||
videoPath: null,
|
videoPath: null,
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
exportedAudioPath: null,
|
exportedAudioPath: null,
|
||||||
@ -163,6 +166,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
|
|
||||||
setBackendUrl: (url) => set({ backendUrl: url }),
|
setBackendUrl: (url) => set({ backendUrl: url }),
|
||||||
|
|
||||||
|
setProjectFilePath: (path) => set({ projectFilePath: path }),
|
||||||
|
|
||||||
setExportedAudioPath: (path) => set({ exportedAudioPath: path }),
|
setExportedAudioPath: (path) => set({ exportedAudioPath: path }),
|
||||||
|
|
||||||
setTranscriptionModel: (model) => set({ transcriptionModel: model }),
|
setTranscriptionModel: (model) => set({ transcriptionModel: model }),
|
||||||
@ -203,6 +208,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
...initialState,
|
...initialState,
|
||||||
backendUrl,
|
backendUrl,
|
||||||
zonePreviewPaddingSeconds,
|
zonePreviewPaddingSeconds,
|
||||||
|
projectFilePath: null,
|
||||||
videoPath: path,
|
videoPath: path,
|
||||||
videoUrl: url,
|
videoUrl: url,
|
||||||
});
|
});
|
||||||
@ -478,7 +484,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadProject: (data) => {
|
loadProject: (data) => {
|
||||||
const { backendUrl, zonePreviewPaddingSeconds } = get();
|
const { backendUrl, zonePreviewPaddingSeconds, projectFilePath } = get();
|
||||||
const url = `${backendUrl}/file?path=${encodeURIComponent(data.videoPath)}`;
|
const url = `${backendUrl}/file?path=${encodeURIComponent(data.videoPath)}`;
|
||||||
|
|
||||||
let globalIdx = 0;
|
let globalIdx = 0;
|
||||||
@ -492,6 +498,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
...initialState,
|
...initialState,
|
||||||
backendUrl,
|
backendUrl,
|
||||||
zonePreviewPaddingSeconds,
|
zonePreviewPaddingSeconds,
|
||||||
|
projectFilePath,
|
||||||
videoPath: data.videoPath,
|
videoPath: data.videoPath,
|
||||||
videoUrl: url,
|
videoUrl: url,
|
||||||
words: data.words || [],
|
words: data.words || [],
|
||||||
@ -514,7 +521,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
const { zonePreviewPaddingSeconds } = get();
|
const { zonePreviewPaddingSeconds } = get();
|
||||||
set({ ...initialState, zonePreviewPaddingSeconds });
|
set({ ...initialState, zonePreviewPaddingSeconds, projectFilePath: null });
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{ limit: 100 },
|
{ limit: 100 },
|
||||||
|
|||||||
Reference in New Issue
Block a user