speed zones work now
This commit is contained in:
@ -37,7 +37,6 @@ export default function App() {
|
|||||||
exportedAudioPath,
|
exportedAudioPath,
|
||||||
words,
|
words,
|
||||||
segments,
|
segments,
|
||||||
deletedRanges,
|
|
||||||
cutRanges,
|
cutRanges,
|
||||||
muteRanges,
|
muteRanges,
|
||||||
gainRanges,
|
gainRanges,
|
||||||
@ -61,6 +60,7 @@ export default function App() {
|
|||||||
} = useEditorStore();
|
} = useEditorStore();
|
||||||
|
|
||||||
const [activePanel, setActivePanel] = useState<Panel>(null);
|
const [activePanel, setActivePanel] = useState<Panel>(null);
|
||||||
|
const [projectName, setProjectName] = useState<string | null>(null);
|
||||||
const [whisperModel, setWhisperModel] = useState('base');
|
const [whisperModel, setWhisperModel] = useState('base');
|
||||||
useEffect(() => { if (transcriptionModel) setWhisperModel(transcriptionModel); }, [transcriptionModel]);
|
useEffect(() => { if (transcriptionModel) setWhisperModel(transcriptionModel); }, [transcriptionModel]);
|
||||||
const [cutMode, setCutMode] = useState(false);
|
const [cutMode, setCutMode] = useState(false);
|
||||||
@ -81,7 +81,6 @@ export default function App() {
|
|||||||
exportedAudioPath,
|
exportedAudioPath,
|
||||||
words,
|
words,
|
||||||
segments,
|
segments,
|
||||||
deletedRanges,
|
|
||||||
cutRanges,
|
cutRanges,
|
||||||
muteRanges,
|
muteRanges,
|
||||||
gainRanges,
|
gainRanges,
|
||||||
@ -96,7 +95,6 @@ export default function App() {
|
|||||||
exportedAudioPath,
|
exportedAudioPath,
|
||||||
words,
|
words,
|
||||||
segments,
|
segments,
|
||||||
deletedRanges,
|
|
||||||
cutRanges,
|
cutRanges,
|
||||||
muteRanges,
|
muteRanges,
|
||||||
gainRanges,
|
gainRanges,
|
||||||
@ -116,8 +114,7 @@ export default function App() {
|
|||||||
exportedAudioPath: data.exportedAudioPath ?? null,
|
exportedAudioPath: data.exportedAudioPath ?? null,
|
||||||
words: data.words || [],
|
words: data.words || [],
|
||||||
segments: data.segments || [],
|
segments: data.segments || [],
|
||||||
deletedRanges: data.deletedRanges || [],
|
cutRanges: [...(data.cutRanges || []), ...(data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end }))],
|
||||||
cutRanges: data.cutRanges || [],
|
|
||||||
muteRanges: data.muteRanges || [],
|
muteRanges: data.muteRanges || [],
|
||||||
gainRanges: data.gainRanges || [],
|
gainRanges: data.gainRanges || [],
|
||||||
speedRanges: data.speedRanges || [],
|
speedRanges: data.speedRanges || [],
|
||||||
@ -183,6 +180,7 @@ export default function App() {
|
|||||||
const content = await window.electronAPI!.readFile(projectPath);
|
const content = await window.electronAPI!.readFile(projectPath);
|
||||||
const data = JSON.parse(content);
|
const data = JSON.parse(content);
|
||||||
loadProjectFromData(data);
|
loadProjectFromData(data);
|
||||||
|
setProjectName(projectPath.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load project:', err);
|
console.error('Failed to load project:', err);
|
||||||
alert(`Failed to load project: ${err}`);
|
alert(`Failed to load project: ${err}`);
|
||||||
@ -197,6 +195,7 @@ export default function App() {
|
|||||||
const data = useEditorStore.getState().saveProject();
|
const data = useEditorStore.getState().saveProject();
|
||||||
const path = savePath.endsWith('.aive') ? savePath : `${savePath}.aive`;
|
const path = savePath.endsWith('.aive') ? savePath : `${savePath}.aive`;
|
||||||
await window.electronAPI!.writeFile(path, JSON.stringify(data, null, 2));
|
await window.electronAPI!.writeFile(path, JSON.stringify(data, null, 2));
|
||||||
|
setProjectName(path.split(/[/\\]/).pop()?.replace(/\.aive$/i, '') ?? null);
|
||||||
if (projectSignature) {
|
if (projectSignature) {
|
||||||
setLastSavedSignature(projectSignature);
|
setLastSavedSignature(projectSignature);
|
||||||
}
|
}
|
||||||
@ -599,7 +598,13 @@ export default function App() {
|
|||||||
<div className="w-1/2 border-l border-editor-border flex flex-col min-h-0">
|
<div className="w-1/2 border-l border-editor-border flex flex-col min-h-0">
|
||||||
{videoPath && (
|
{videoPath && (
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-editor-border shrink-0 bg-editor-surface/50">
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-editor-border shrink-0 bg-editor-surface/50">
|
||||||
<span className="text-xs font-medium truncate text-editor-text">{videoPath.split(/[\/]/).pop()}</span>
|
{projectName && (
|
||||||
|
<span className="text-xs font-semibold text-editor-accent shrink-0">{projectName}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium truncate text-editor-text">{videoPath.split(/[/\\]/).pop()}</span>
|
||||||
|
<span className="text-xs text-editor-text-muted ml-auto shrink-0">
|
||||||
|
{words.length} words · {cutRanges.length} cuts · {muteRanges.length} mutes · {gainRanges.length} gains · {speedRanges.length} speeds
|
||||||
|
</span>
|
||||||
{transcriptionModel && (
|
{transcriptionModel && (
|
||||||
<span className="px-1.5 py-0.5 rounded border border-editor-border bg-editor-surface text-[10px] uppercase tracking-wide text-editor-text-muted shrink-0">
|
<span className="px-1.5 py-0.5 rounded border border-editor-border bg-editor-surface text-[10px] uppercase tracking-wide text-editor-text-muted shrink-0">
|
||||||
{transcriptionModel}
|
{transcriptionModel}
|
||||||
|
|||||||
@ -4,10 +4,10 @@ import { Download, Loader2, Zap, Cog, Info } from 'lucide-react';
|
|||||||
import type { ExportOptions } from '../types/project';
|
import type { ExportOptions } from '../types/project';
|
||||||
|
|
||||||
export default function ExportDialog() {
|
export default function ExportDialog() {
|
||||||
const { videoPath, words, deletedRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
|
const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
|
||||||
useEditorStore();
|
useEditorStore();
|
||||||
|
|
||||||
const hasCuts = deletedRanges.length > 0;
|
const hasCuts = cutRanges.length > 0;
|
||||||
|
|
||||||
const [options, setOptions] = useState<Omit<ExportOptions, 'outputPath'>>({
|
const [options, setOptions] = useState<Omit<ExportOptions, 'outputPath'>>({
|
||||||
mode: 'fast',
|
mode: 'fast',
|
||||||
@ -35,8 +35,11 @@ export default function ExportDialog() {
|
|||||||
const keepSegments = getKeepSegments();
|
const keepSegments = getKeepSegments();
|
||||||
|
|
||||||
const deletedSet = new Set<number>();
|
const deletedSet = new Set<number>();
|
||||||
for (const range of deletedRanges) {
|
for (const range of cutRanges) {
|
||||||
for (const idx of range.wordIndices) deletedSet.add(idx);
|
for (let i = 0; i < words.length; i++) {
|
||||||
|
const w = words[i];
|
||||||
|
if (w.start >= range.start && w.end <= range.end) deletedSet.add(i);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`${backendUrl}/export`, {
|
const res = await fetch(`${backendUrl}/export`, {
|
||||||
@ -61,7 +64,7 @@ export default function ExportDialog() {
|
|||||||
console.error('Export error:', err);
|
console.error('Export error:', err);
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, deletedRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words]);
|
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-5">
|
<div className="p-4 space-y-5">
|
||||||
|
|||||||
@ -22,7 +22,6 @@ export default function TranscriptEditor({
|
|||||||
}: TranscriptEditorProps) {
|
}: TranscriptEditorProps) {
|
||||||
const words = useEditorStore((s) => s.words);
|
const words = useEditorStore((s) => s.words);
|
||||||
const segments = useEditorStore((s) => s.segments);
|
const segments = useEditorStore((s) => s.segments);
|
||||||
const deletedRanges = useEditorStore((s) => s.deletedRanges);
|
|
||||||
const cutRanges = useEditorStore((s) => s.cutRanges);
|
const cutRanges = useEditorStore((s) => s.cutRanges);
|
||||||
const muteRanges = useEditorStore((s) => s.muteRanges);
|
const muteRanges = useEditorStore((s) => s.muteRanges);
|
||||||
const gainRanges = useEditorStore((s) => s.gainRanges);
|
const gainRanges = useEditorStore((s) => s.gainRanges);
|
||||||
@ -31,7 +30,6 @@ export default function TranscriptEditor({
|
|||||||
const hoveredWordIndex = useEditorStore((s) => s.hoveredWordIndex);
|
const hoveredWordIndex = useEditorStore((s) => s.hoveredWordIndex);
|
||||||
const setSelectedWordIndices = useEditorStore((s) => s.setSelectedWordIndices);
|
const setSelectedWordIndices = useEditorStore((s) => s.setSelectedWordIndices);
|
||||||
const setHoveredWordIndex = useEditorStore((s) => s.setHoveredWordIndex);
|
const setHoveredWordIndex = useEditorStore((s) => s.setHoveredWordIndex);
|
||||||
const restoreRange = useEditorStore((s) => s.restoreRange);
|
|
||||||
const removeCutRange = useEditorStore((s) => s.removeCutRange);
|
const removeCutRange = useEditorStore((s) => s.removeCutRange);
|
||||||
const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
|
const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
|
||||||
const removeGainRange = useEditorStore((s) => s.removeGainRange);
|
const removeGainRange = useEditorStore((s) => s.removeGainRange);
|
||||||
@ -48,14 +46,6 @@ export default function TranscriptEditor({
|
|||||||
const zoneDragStart = useRef<number | null>(null);
|
const zoneDragStart = useRef<number | null>(null);
|
||||||
const [zoneDragRange, setZoneDragRange] = useState<{ start: number; end: number } | null>(null);
|
const [zoneDragRange, setZoneDragRange] = useState<{ start: number; end: number } | null>(null);
|
||||||
|
|
||||||
const deletedSet = useMemo(() => {
|
|
||||||
const s = new Set<number>();
|
|
||||||
for (const range of deletedRanges) {
|
|
||||||
for (const idx of range.wordIndices) s.add(idx);
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}, [deletedRanges]);
|
|
||||||
|
|
||||||
const selectedSet = useMemo(() => new Set(selectedWordIndices), [selectedWordIndices]);
|
const selectedSet = useMemo(() => new Set(selectedWordIndices), [selectedWordIndices]);
|
||||||
|
|
||||||
const [activeWordIndex, setActiveWordIndex] = useState(-1);
|
const [activeWordIndex, setActiveWordIndex] = useState(-1);
|
||||||
@ -170,11 +160,6 @@ export default function TranscriptEditor({
|
|||||||
[setSelectedWordIndices],
|
[setSelectedWordIndices],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getRangeForWord = useCallback(
|
|
||||||
(wordIndex: number) => deletedRanges.find((r) => r.wordIndices.includes(wordIndex)),
|
|
||||||
[deletedRanges],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cutSelectedWords = useCallback(() => {
|
const cutSelectedWords = useCallback(() => {
|
||||||
if (selectedWordIndices.length === 0) return;
|
if (selectedWordIndices.length === 0) return;
|
||||||
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
||||||
@ -257,14 +242,12 @@ export default function TranscriptEditor({
|
|||||||
<p className="text-sm leading-relaxed flex flex-wrap">
|
<p className="text-sm leading-relaxed flex flex-wrap">
|
||||||
{segment.words.map((word, localIndex) => {
|
{segment.words.map((word, localIndex) => {
|
||||||
const globalIndex = (segment.globalStartIndex ?? 0) + localIndex;
|
const globalIndex = (segment.globalStartIndex ?? 0) + localIndex;
|
||||||
const isDeleted = deletedSet.has(globalIndex);
|
|
||||||
const isSelected = selectedSet.has(globalIndex);
|
const isSelected = selectedSet.has(globalIndex);
|
||||||
const isActive = globalIndex === activeWordIndex;
|
const isActive = globalIndex === activeWordIndex;
|
||||||
const isHovered = globalIndex === hoveredWordIndex;
|
const isHovered = globalIndex === hoveredWordIndex;
|
||||||
const isZoneDragSelected = zoneDragRange
|
const isZoneDragSelected = zoneDragRange
|
||||||
? globalIndex >= zoneDragRange.start && globalIndex <= zoneDragRange.end
|
? globalIndex >= zoneDragRange.start && globalIndex <= zoneDragRange.end
|
||||||
: false;
|
: false;
|
||||||
const deletedRange = isDeleted ? getRangeForWord(globalIndex) : null;
|
|
||||||
const cutRange = getCutRangeForWord(globalIndex);
|
const cutRange = getCutRangeForWord(globalIndex);
|
||||||
const muteRange = getMuteRangeForWord(globalIndex);
|
const muteRange = getMuteRangeForWord(globalIndex);
|
||||||
const gainRange = getGainRangeForWord(globalIndex);
|
const gainRange = getGainRangeForWord(globalIndex);
|
||||||
@ -281,7 +264,6 @@ export default function TranscriptEditor({
|
|||||||
onMouseLeave={() => setHoveredWordIndex(null)}
|
onMouseLeave={() => setHoveredWordIndex(null)}
|
||||||
className={`
|
className={`
|
||||||
relative px-[2px] py-[1px] rounded cursor-pointer transition-colors
|
relative px-[2px] py-[1px] rounded cursor-pointer transition-colors
|
||||||
${isDeleted ? 'line-through text-editor-text-muted/40 bg-editor-word-deleted' : ''}
|
|
||||||
${cutRange ? 'bg-red-500/20 text-red-100' : ''}
|
${cutRange ? 'bg-red-500/20 text-red-100' : ''}
|
||||||
${muteRange ? 'bg-blue-500/20 text-blue-100' : ''}
|
${muteRange ? 'bg-blue-500/20 text-blue-100' : ''}
|
||||||
${gainRange ? 'bg-amber-500/20 text-amber-100' : ''}
|
${gainRange ? 'bg-amber-500/20 text-amber-100' : ''}
|
||||||
@ -290,23 +272,12 @@ export default function TranscriptEditor({
|
|||||||
${isZoneDragSelected && muteMode ? 'bg-blue-500/30 ring-1 ring-blue-400/60' : ''}
|
${isZoneDragSelected && muteMode ? 'bg-blue-500/30 ring-1 ring-blue-400/60' : ''}
|
||||||
${isZoneDragSelected && gainMode ? 'bg-amber-500/30 ring-1 ring-amber-400/60' : ''}
|
${isZoneDragSelected && gainMode ? 'bg-amber-500/30 ring-1 ring-amber-400/60' : ''}
|
||||||
${isZoneDragSelected && speedMode ? 'bg-emerald-500/30 ring-1 ring-emerald-400/60' : ''}
|
${isZoneDragSelected && speedMode ? 'bg-emerald-500/30 ring-1 ring-emerald-400/60' : ''}
|
||||||
${isSelected && !isDeleted && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-selected text-white' : ''}
|
${isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-selected text-white' : ''}
|
||||||
${isActive && !isDeleted && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''}
|
${isActive && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''}
|
||||||
${isHovered && !isDeleted && !isSelected && !isActive && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-hover' : ''}
|
${isHovered && !isSelected && !isActive && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-hover' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{word.word}{' '}
|
{word.word}{' '}
|
||||||
{isDeleted && isHovered && deletedRange && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
restoreRange(deletedRange.id);
|
|
||||||
}}
|
|
||||||
className="absolute -top-5 left-1/2 -translate-x-1/2 flex items-center gap-0.5 px-1.5 py-0.5 bg-editor-surface border border-editor-border rounded text-[10px] text-editor-success whitespace-nowrap z-10"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-2.5 h-2.5" /> Restore
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{(cutRange || muteRange || gainRange || speedRange) && isHovered && (
|
{(cutRange || muteRange || gainRange || speedRange) && isHovered && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -328,16 +299,12 @@ export default function TranscriptEditor({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[segments, deletedSet, selectedSet, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getRangeForWord, getCutRangeForWord, getMuteRangeForWord, getGainRangeForWord, getSpeedRangeForWord, restoreRange, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange, zoneDragRange, cutMode, muteMode, gainMode, speedMode],
|
[segments, selectedSet, activeWordIndex, hoveredWordIndex, handleWordMouseDown, handleWordMouseEnter, setHoveredWordIndex, getCutRangeForWord, getMuteRangeForWord, getGainRangeForWord, getSpeedRangeForWord, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange, zoneDragRange, cutMode, muteMode, gainMode, speedMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-editor-border shrink-0">
|
<div className="flex items-center justify-end gap-2 px-4 py-2 border-b border-editor-border shrink-0">
|
||||||
<span className="text-xs text-editor-text-muted flex-1">
|
|
||||||
{words.length} words · {deletedRanges.length} cuts · {cutRanges.length} cut ranges · {muteRanges.length} mute ranges · {gainRanges.length} gain ranges
|
|
||||||
· {speedRanges.length} speed ranges
|
|
||||||
</span>
|
|
||||||
{selectedWordIndices.length > 0 && (
|
{selectedWordIndices.length > 0 && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -127,7 +127,6 @@ export default function WaveformTimeline({
|
|||||||
const videoPath = useEditorStore((s) => s.videoPath);
|
const videoPath = useEditorStore((s) => s.videoPath);
|
||||||
const backendUrl = useEditorStore((s) => s.backendUrl);
|
const backendUrl = useEditorStore((s) => s.backendUrl);
|
||||||
const duration = useEditorStore((s) => s.duration);
|
const duration = useEditorStore((s) => s.duration);
|
||||||
const deletedRanges = useEditorStore((s) => s.deletedRanges);
|
|
||||||
const cutRanges = useEditorStore((s) => s.cutRanges);
|
const cutRanges = useEditorStore((s) => s.cutRanges);
|
||||||
const muteRanges = useEditorStore((s) => s.muteRanges);
|
const muteRanges = useEditorStore((s) => s.muteRanges);
|
||||||
const gainRanges = useEditorStore((s) => s.gainRanges);
|
const gainRanges = useEditorStore((s) => s.gainRanges);
|
||||||
@ -322,13 +321,6 @@ export default function WaveformTimeline({
|
|||||||
const waveTop = RULER_H + 1;
|
const waveTop = RULER_H + 1;
|
||||||
const waveH = height - waveTop;
|
const waveH = height - waveTop;
|
||||||
|
|
||||||
for (const range of deletedRanges) {
|
|
||||||
const x1 = (range.start - scroll) * pxPerSec;
|
|
||||||
const x2 = (range.end - scroll) * pxPerSec;
|
|
||||||
ctx.fillStyle = 'rgba(239, 68, 68, 0.15)';
|
|
||||||
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 = (range.start - scroll) * pxPerSec;
|
||||||
@ -430,9 +422,9 @@ export default function WaveformTimeline({
|
|||||||
ctx.fillStyle = '#d1fae5';
|
ctx.fillStyle = '#d1fae5';
|
||||||
ctx.font = '10px sans-serif';
|
ctx.font = '10px sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'top';
|
||||||
if (centerX > 12 && centerX < width - 12) {
|
if (centerX > 12 && centerX < width - 12) {
|
||||||
ctx.fillText(`${range.speed.toFixed(2)}x`, centerX, waveTop + waveH / 2);
|
ctx.fillText(`${range.speed.toFixed(2)}x`, centerX, waveTop + 4);
|
||||||
}
|
}
|
||||||
ctx.textAlign = 'start';
|
ctx.textAlign = 'start';
|
||||||
ctx.textBaseline = 'alphabetic';
|
ctx.textBaseline = 'alphabetic';
|
||||||
@ -483,7 +475,6 @@ export default function WaveformTimeline({
|
|||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}, [
|
}, [
|
||||||
deletedRanges,
|
|
||||||
cutRanges,
|
cutRanges,
|
||||||
muteRanges,
|
muteRanges,
|
||||||
gainRanges,
|
gainRanges,
|
||||||
@ -506,7 +497,7 @@ export default function WaveformTimeline({
|
|||||||
drawStaticWaveformRef.current = drawStaticWaveform;
|
drawStaticWaveformRef.current = drawStaticWaveform;
|
||||||
}, [drawStaticWaveform]);
|
}, [drawStaticWaveform]);
|
||||||
|
|
||||||
// Redraw static layer when deletedRanges change
|
// Redraw static layer when cutRanges change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
drawStaticWaveform();
|
drawStaticWaveform();
|
||||||
}, [drawStaticWaveform]);
|
}, [drawStaticWaveform]);
|
||||||
|
|||||||
@ -160,7 +160,6 @@ async function saveProject() {
|
|||||||
videoPath: state.videoPath,
|
videoPath: state.videoPath,
|
||||||
words: state.words,
|
words: state.words,
|
||||||
segments: state.segments,
|
segments: state.segments,
|
||||||
deletedRanges: state.deletedRanges,
|
|
||||||
language: state.language,
|
language: state.language,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
modifiedAt: new Date().toISOString(),
|
modifiedAt: new Date().toISOString(),
|
||||||
|
|||||||
@ -7,18 +7,75 @@ export function useVideoSync(videoRef: React.RefObject<HTMLVideoElement | null>)
|
|||||||
setCurrentTime,
|
setCurrentTime,
|
||||||
setDuration,
|
setDuration,
|
||||||
setIsPlaying,
|
setIsPlaying,
|
||||||
deletedRanges,
|
|
||||||
cutRanges,
|
cutRanges,
|
||||||
muteRanges,
|
muteRanges,
|
||||||
|
speedRanges,
|
||||||
} = useEditorStore();
|
} = useEditorStore();
|
||||||
|
|
||||||
|
const getPlaybackRateAtTime = useCallback(
|
||||||
|
(time: number) => {
|
||||||
|
for (const range of speedRanges) {
|
||||||
|
if (time >= range.start && time < range.end) {
|
||||||
|
return range.speed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
[speedRanges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyVideoEffects = useCallback(
|
||||||
|
(video: HTMLVideoElement) => {
|
||||||
|
let t = video.currentTime;
|
||||||
|
|
||||||
|
const allSkipRanges = [...cutRanges];
|
||||||
|
let skipCount = 0;
|
||||||
|
const maxSkips = 10;
|
||||||
|
|
||||||
|
while (skipCount < maxSkips) {
|
||||||
|
let shouldSkip = false;
|
||||||
|
for (const range of allSkipRanges) {
|
||||||
|
if (t >= range.start && t < range.end) {
|
||||||
|
t = range.end;
|
||||||
|
shouldSkip = true;
|
||||||
|
skipCount++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!shouldSkip) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipCount > 0 && video.currentTime !== t) {
|
||||||
|
video.currentTime = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shouldMute = false;
|
||||||
|
for (const range of muteRanges) {
|
||||||
|
if (t >= range.start && t < range.end) {
|
||||||
|
shouldMute = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
video.muted = shouldMute;
|
||||||
|
|
||||||
|
const playbackRate = getPlaybackRateAtTime(t);
|
||||||
|
if (video.playbackRate !== playbackRate) {
|
||||||
|
video.playbackRate = playbackRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentTime(t);
|
||||||
|
return t;
|
||||||
|
},
|
||||||
|
[cutRanges, muteRanges, getPlaybackRateAtTime, setCurrentTime],
|
||||||
|
);
|
||||||
|
|
||||||
const seekTo = useCallback(
|
const seekTo = useCallback(
|
||||||
(time: number) => {
|
(time: number) => {
|
||||||
if (videoRef.current) {
|
if (videoRef.current) {
|
||||||
let targetTime = time;
|
let targetTime = time;
|
||||||
|
|
||||||
// If seeking into cut or deleted ranges, skip to the end (handle overlapping/chained ranges)
|
// If seeking into cut or deleted ranges, skip to the end (handle overlapping/chained ranges)
|
||||||
const allSkipRanges = [...deletedRanges, ...cutRanges];
|
const allSkipRanges = [...cutRanges];
|
||||||
let skipCount = 0;
|
let skipCount = 0;
|
||||||
const maxSkips = 10; // Prevent infinite loops
|
const maxSkips = 10; // Prevent infinite loops
|
||||||
|
|
||||||
@ -36,10 +93,11 @@ export function useVideoSync(videoRef: React.RefObject<HTMLVideoElement | null>)
|
|||||||
}
|
}
|
||||||
|
|
||||||
videoRef.current.currentTime = targetTime;
|
videoRef.current.currentTime = targetTime;
|
||||||
|
videoRef.current.playbackRate = getPlaybackRateAtTime(targetTime);
|
||||||
setCurrentTime(targetTime);
|
setCurrentTime(targetTime);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[videoRef, deletedRanges, cutRanges, setCurrentTime],
|
[videoRef, cutRanges, getPlaybackRateAtTime, setCurrentTime],
|
||||||
);
|
);
|
||||||
|
|
||||||
const togglePlay = useCallback(() => {
|
const togglePlay = useCallback(() => {
|
||||||
@ -55,65 +113,52 @@ export function useVideoSync(videoRef: React.RefObject<HTMLVideoElement | null>)
|
|||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
|
const updateWhilePlaying = () => {
|
||||||
|
applyVideoEffects(video);
|
||||||
|
if (!video.paused && !video.ended) {
|
||||||
|
rafRef.current = requestAnimationFrame(updateWhilePlaying);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onTimeUpdate = () => {
|
const onTimeUpdate = () => {
|
||||||
cancelAnimationFrame(rafRef.current);
|
cancelAnimationFrame(rafRef.current);
|
||||||
rafRef.current = requestAnimationFrame(() => {
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
let t = video.currentTime;
|
applyVideoEffects(video);
|
||||||
|
|
||||||
// Skip over deleted ranges and cut ranges (handle overlapping/chained ranges)
|
|
||||||
const allSkipRanges = [...deletedRanges, ...cutRanges];
|
|
||||||
let skipCount = 0;
|
|
||||||
const maxSkips = 10; // Prevent infinite loops
|
|
||||||
|
|
||||||
while (skipCount < maxSkips) {
|
|
||||||
let shouldSkip = false;
|
|
||||||
for (const range of allSkipRanges) {
|
|
||||||
if (t >= range.start && t < range.end) {
|
|
||||||
t = range.end;
|
|
||||||
shouldSkip = true;
|
|
||||||
skipCount++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!shouldSkip) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skipCount > 0) {
|
|
||||||
video.currentTime = t;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mute/unmute based on mute ranges
|
|
||||||
let shouldMute = false;
|
|
||||||
for (const range of muteRanges) {
|
|
||||||
if (t >= range.start && t < range.end) {
|
|
||||||
shouldMute = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
video.muted = shouldMute;
|
|
||||||
|
|
||||||
setCurrentTime(t);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPlay = () => setIsPlaying(true);
|
const onPlay = () => {
|
||||||
const onPause = () => setIsPlaying(false);
|
setIsPlaying(true);
|
||||||
const onLoadedMetadata = () => setDuration(video.duration);
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = requestAnimationFrame(updateWhilePlaying);
|
||||||
|
};
|
||||||
|
const onPause = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
applyVideoEffects(video);
|
||||||
|
};
|
||||||
|
const onLoadedMetadata = () => {
|
||||||
|
setDuration(video.duration);
|
||||||
|
applyVideoEffects(video);
|
||||||
|
};
|
||||||
|
const onSeeked = () => applyVideoEffects(video);
|
||||||
|
|
||||||
video.addEventListener('timeupdate', onTimeUpdate);
|
video.addEventListener('timeupdate', onTimeUpdate);
|
||||||
video.addEventListener('play', onPlay);
|
video.addEventListener('play', onPlay);
|
||||||
video.addEventListener('pause', onPause);
|
video.addEventListener('pause', onPause);
|
||||||
video.addEventListener('loadedmetadata', onLoadedMetadata);
|
video.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
video.addEventListener('seeked', onSeeked);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
video.removeEventListener('timeupdate', onTimeUpdate);
|
video.removeEventListener('timeupdate', onTimeUpdate);
|
||||||
video.removeEventListener('play', onPlay);
|
video.removeEventListener('play', onPlay);
|
||||||
video.removeEventListener('pause', onPause);
|
video.removeEventListener('pause', onPause);
|
||||||
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
video.removeEventListener('seeked', onSeeked);
|
||||||
cancelAnimationFrame(rafRef.current);
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
video.playbackRate = 1;
|
||||||
};
|
};
|
||||||
}, [videoRef, deletedRanges, cutRanges, muteRanges, setCurrentTime, setIsPlaying, setDuration]);
|
}, [videoRef, applyVideoEffects, setIsPlaying, setDuration]);
|
||||||
|
|
||||||
return { seekTo, togglePlay };
|
return { seekTo, togglePlay };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { temporal } from 'zundo';
|
|||||||
import type {
|
import type {
|
||||||
Word,
|
Word,
|
||||||
Segment,
|
Segment,
|
||||||
DeletedRange,
|
|
||||||
CutRange,
|
CutRange,
|
||||||
MuteRange,
|
MuteRange,
|
||||||
GainRange,
|
GainRange,
|
||||||
@ -21,7 +20,6 @@ interface EditorState {
|
|||||||
exportedAudioPath: string | null; // path to modified audio from a previous export
|
exportedAudioPath: string | null; // path to modified audio from a previous export
|
||||||
words: Word[];
|
words: Word[];
|
||||||
segments: Segment[];
|
segments: Segment[];
|
||||||
deletedRanges: DeletedRange[];
|
|
||||||
cutRanges: CutRange[];
|
cutRanges: CutRange[];
|
||||||
muteRanges: MuteRange[];
|
muteRanges: MuteRange[];
|
||||||
gainRanges: GainRange[];
|
gainRanges: GainRange[];
|
||||||
@ -61,7 +59,6 @@ interface EditorActions {
|
|||||||
setHoveredWordIndex: (index: number | null) => void;
|
setHoveredWordIndex: (index: number | null) => void;
|
||||||
deleteSelectedWords: () => void;
|
deleteSelectedWords: () => void;
|
||||||
deleteWordRange: (startIndex: number, endIndex: number) => void;
|
deleteWordRange: (startIndex: number, endIndex: number) => void;
|
||||||
restoreRange: (rangeId: string) => void;
|
|
||||||
addCutRange: (start: number, end: number, trimGroupId?: string) => void;
|
addCutRange: (start: number, end: number, trimGroupId?: string) => void;
|
||||||
addMuteRange: (start: number, end: number) => void;
|
addMuteRange: (start: number, end: number) => void;
|
||||||
addGainRange: (start: number, end: number, gainDb: number) => void;
|
addGainRange: (start: number, end: number, gainDb: number) => void;
|
||||||
@ -97,7 +94,6 @@ const initialState: EditorState = {
|
|||||||
exportedAudioPath: null,
|
exportedAudioPath: null,
|
||||||
words: [],
|
words: [],
|
||||||
segments: [],
|
segments: [],
|
||||||
deletedRanges: [],
|
|
||||||
cutRanges: [],
|
cutRanges: [],
|
||||||
muteRanges: [],
|
muteRanges: [],
|
||||||
gainRanges: [],
|
gainRanges: [],
|
||||||
@ -159,7 +155,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
setTranscriptionModel: (model) => set({ transcriptionModel: model }),
|
setTranscriptionModel: (model) => set({ transcriptionModel: model }),
|
||||||
|
|
||||||
saveProject: (): ProjectFile => {
|
saveProject: (): ProjectFile => {
|
||||||
const { videoPath, words, segments, deletedRanges, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, exportedAudioPath } = get();
|
const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, exportedAudioPath } = get();
|
||||||
if (!videoPath) throw new Error('No video loaded');
|
if (!videoPath) throw new Error('No video loaded');
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
// Strip globalStartIndex (runtime-only field) before persisting.
|
// Strip globalStartIndex (runtime-only field) before persisting.
|
||||||
@ -175,7 +171,6 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
transcriptionModel: transcriptionModel ?? undefined,
|
transcriptionModel: transcriptionModel ?? undefined,
|
||||||
words,
|
words,
|
||||||
segments: persistSegments as unknown as Segment[],
|
segments: persistSegments as unknown as Segment[],
|
||||||
deletedRanges,
|
|
||||||
cutRanges,
|
cutRanges,
|
||||||
muteRanges,
|
muteRanges,
|
||||||
gainRanges,
|
gainRanges,
|
||||||
@ -210,7 +205,6 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
words: result.words,
|
words: result.words,
|
||||||
segments: annotatedSegments,
|
segments: annotatedSegments,
|
||||||
language: result.language,
|
language: result.language,
|
||||||
deletedRanges: [],
|
|
||||||
selectedWordIndices: [],
|
selectedWordIndices: [],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -222,44 +216,20 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
setHoveredWordIndex: (index) => set({ hoveredWordIndex: index }),
|
setHoveredWordIndex: (index) => set({ hoveredWordIndex: index }),
|
||||||
|
|
||||||
deleteSelectedWords: () => {
|
deleteSelectedWords: () => {
|
||||||
const { selectedWordIndices, words, deletedRanges } = get();
|
const { selectedWordIndices, words } = get();
|
||||||
if (selectedWordIndices.length === 0) return;
|
if (selectedWordIndices.length === 0) return;
|
||||||
|
|
||||||
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
||||||
const startWord = words[sorted[0]];
|
const startWord = words[sorted[0]];
|
||||||
const endWord = words[sorted[sorted.length - 1]];
|
const endWord = words[sorted[sorted.length - 1]];
|
||||||
|
|
||||||
const newRange: DeletedRange = {
|
get().addCutRange(startWord.start, endWord.end);
|
||||||
id: `dr_${nextRangeId++}`,
|
set({ selectedWordIndices: [] });
|
||||||
start: startWord.start,
|
|
||||||
end: endWord.end,
|
|
||||||
wordIndices: sorted,
|
|
||||||
};
|
|
||||||
|
|
||||||
set({
|
|
||||||
deletedRanges: [...deletedRanges, newRange],
|
|
||||||
selectedWordIndices: [],
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteWordRange: (startIndex, endIndex) => {
|
deleteWordRange: (startIndex, endIndex) => {
|
||||||
const { words, deletedRanges } = get();
|
const { words } = get();
|
||||||
const indices = [];
|
get().addCutRange(words[startIndex].start, words[endIndex].end);
|
||||||
for (let i = startIndex; i <= endIndex; i++) indices.push(i);
|
|
||||||
|
|
||||||
const newRange: DeletedRange = {
|
|
||||||
id: `dr_${nextRangeId++}`,
|
|
||||||
start: words[startIndex].start,
|
|
||||||
end: words[endIndex].end,
|
|
||||||
wordIndices: indices,
|
|
||||||
};
|
|
||||||
|
|
||||||
set({ deletedRanges: [...deletedRanges, newRange] });
|
|
||||||
},
|
|
||||||
|
|
||||||
restoreRange: (rangeId) => {
|
|
||||||
const { deletedRanges } = get();
|
|
||||||
set({ deletedRanges: deletedRanges.filter((r) => r.id !== rangeId) });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
addCutRange: (start, end, trimGroupId) => {
|
addCutRange: (start, end, trimGroupId) => {
|
||||||
@ -438,15 +408,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
getKeepSegments: () => {
|
getKeepSegments: () => {
|
||||||
const { words, deletedRanges, cutRanges, duration } = get();
|
const { words, cutRanges, duration } = get();
|
||||||
if (words.length === 0) return [{ start: 0, end: duration }];
|
if (words.length === 0) return [{ start: 0, end: duration }];
|
||||||
|
|
||||||
const deletedSet = new Set<number>();
|
const deletedSet = new Set<number>();
|
||||||
for (const range of deletedRanges) {
|
|
||||||
for (const idx of range.wordIndices) deletedSet.add(idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also exclude words that fall within cut ranges
|
|
||||||
for (const cutRange of cutRanges) {
|
for (const cutRange of cutRanges) {
|
||||||
for (let i = 0; i < words.length; i++) {
|
for (let i = 0; i < words.length; i++) {
|
||||||
const word = words[i];
|
const word = words[i];
|
||||||
@ -508,8 +473,11 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
videoUrl: url,
|
videoUrl: url,
|
||||||
words: data.words || [],
|
words: data.words || [],
|
||||||
segments: annotatedSegments,
|
segments: annotatedSegments,
|
||||||
deletedRanges: data.deletedRanges || [],
|
// Backward compat: merge legacy deletedRanges into cutRanges as time-range cuts
|
||||||
cutRanges: data.cutRanges || [],
|
cutRanges: [
|
||||||
|
...(data.cutRanges || []),
|
||||||
|
...(data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end })),
|
||||||
|
],
|
||||||
muteRanges: data.muteRanges || [],
|
muteRanges: data.muteRanges || [],
|
||||||
gainRanges: data.gainRanges || [],
|
gainRanges: data.gainRanges || [],
|
||||||
speedRanges: data.speedRanges || [],
|
speedRanges: data.speedRanges || [],
|
||||||
|
|||||||
@ -21,11 +21,6 @@ export interface TimeRange {
|
|||||||
end: number;
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeletedRange extends TimeRange {
|
|
||||||
id: string;
|
|
||||||
wordIndices: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CutRange extends TimeRange {
|
export interface CutRange extends TimeRange {
|
||||||
id: string;
|
id: string;
|
||||||
trimGroupId?: string;
|
trimGroupId?: string;
|
||||||
@ -71,7 +66,6 @@ export interface ProjectFile {
|
|||||||
transcriptionModel?: string;
|
transcriptionModel?: string;
|
||||||
words: Word[];
|
words: Word[];
|
||||||
segments: Segment[];
|
segments: Segment[];
|
||||||
deletedRanges: DeletedRange[];
|
|
||||||
cutRanges: CutRange[];
|
cutRanges: CutRange[];
|
||||||
muteRanges: MuteRange[];
|
muteRanges: MuteRange[];
|
||||||
gainRanges?: GainRange[];
|
gainRanges?: GainRange[];
|
||||||
|
|||||||
Reference in New Issue
Block a user