Initial CutScript release - Open-source AI-powered text-based video editor
CutScript is a local-first, Descript-like video editor where you edit video by editing text. Delete a word from the transcript and it's cut from the video. Features: - Word-level transcription with WhisperX - Text-based video editing with undo/redo - AI filler word removal (Ollama/OpenAI/Claude) - AI clip creation for shorts - Waveform timeline with virtualized transcript - FFmpeg stream-copy (fast) and re-encode (4K) export - Caption burn-in and sidecar SRT generation - Studio Sound audio enhancement (DeepFilterNet) - Keyboard shortcuts (J/K/L, Space, Delete, Ctrl+Z/S/E) - Encrypted API key storage - Project save/load (.aive files) Architecture: - Electron + React + Tailwind (frontend) - FastAPI + Python (backend) - WhisperX for transcription - FFmpeg for video processing - Multi-provider AI support Performance optimizations: - RAF-throttled time updates - Zustand selectors for granular subscriptions - Dual-canvas waveform rendering - Virtualized transcript with react-virtuoso Built on top of DataAnts-AI/VideoTranscriber, completely rewritten as a desktop application. License: MIT
This commit is contained in:
129
frontend/src/store/aiStore.ts
Normal file
129
frontend/src/store/aiStore.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { AIProvider, AIProviderConfig, FillerWordResult, ClipSuggestion } from '../types/project';
|
||||
|
||||
const ENCRYPTED_KEY_PREFIX = 'aive_enc_';
|
||||
|
||||
interface AIState {
|
||||
providers: Record<AIProvider, AIProviderConfig>;
|
||||
defaultProvider: AIProvider;
|
||||
customFillerWords: string;
|
||||
fillerResult: FillerWordResult | null;
|
||||
clipSuggestions: ClipSuggestion[];
|
||||
isProcessing: boolean;
|
||||
processingMessage: string;
|
||||
_keysHydrated: boolean;
|
||||
}
|
||||
|
||||
interface AIActions {
|
||||
setProviderConfig: (provider: AIProvider, config: Partial<AIProviderConfig>) => void;
|
||||
setDefaultProvider: (provider: AIProvider) => void;
|
||||
setCustomFillerWords: (words: string) => void;
|
||||
setFillerResult: (result: FillerWordResult | null) => void;
|
||||
setClipSuggestions: (suggestions: ClipSuggestion[]) => void;
|
||||
setProcessing: (active: boolean, message?: string) => void;
|
||||
hydrateKeys: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function encryptAndStore(key: string, value: string): Promise<void> {
|
||||
if (!value) {
|
||||
localStorage.removeItem(ENCRYPTED_KEY_PREFIX + key);
|
||||
return;
|
||||
}
|
||||
if (window.electronAPI) {
|
||||
const encrypted = await window.electronAPI.encryptString(value);
|
||||
localStorage.setItem(ENCRYPTED_KEY_PREFIX + key, encrypted);
|
||||
} else {
|
||||
localStorage.setItem(ENCRYPTED_KEY_PREFIX + key, btoa(value));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndDecrypt(key: string): Promise<string> {
|
||||
const stored = localStorage.getItem(ENCRYPTED_KEY_PREFIX + key);
|
||||
if (!stored) return '';
|
||||
if (window.electronAPI) {
|
||||
try {
|
||||
return await window.electronAPI.decryptString(stored);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
try {
|
||||
return atob(stored);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export const useAIStore = create<AIState & AIActions>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
providers: {
|
||||
ollama: { provider: 'ollama', baseUrl: 'http://localhost:11434', model: 'llama3' },
|
||||
openai: { provider: 'openai', apiKey: '', model: 'gpt-4o' },
|
||||
claude: { provider: 'claude', apiKey: '', model: 'claude-sonnet-4-20250514' },
|
||||
},
|
||||
defaultProvider: 'ollama',
|
||||
customFillerWords: '',
|
||||
fillerResult: null,
|
||||
clipSuggestions: [],
|
||||
isProcessing: false,
|
||||
processingMessage: '',
|
||||
_keysHydrated: false,
|
||||
|
||||
setProviderConfig: (provider, config) => {
|
||||
set((state) => ({
|
||||
providers: {
|
||||
...state.providers,
|
||||
[provider]: { ...state.providers[provider], ...config },
|
||||
},
|
||||
}));
|
||||
|
||||
if (config.apiKey !== undefined) {
|
||||
encryptAndStore(`${provider}_apiKey`, config.apiKey);
|
||||
}
|
||||
},
|
||||
|
||||
setDefaultProvider: (provider) => set({ defaultProvider: provider }),
|
||||
|
||||
setCustomFillerWords: (words) => set({ customFillerWords: words }),
|
||||
|
||||
setFillerResult: (result) => set({ fillerResult: result }),
|
||||
|
||||
setClipSuggestions: (suggestions) => set({ clipSuggestions: suggestions }),
|
||||
|
||||
setProcessing: (active, message) =>
|
||||
set({ isProcessing: active, processingMessage: message ?? '' }),
|
||||
|
||||
hydrateKeys: async () => {
|
||||
const [openaiKey, claudeKey] = await Promise.all([
|
||||
loadAndDecrypt('openai_apiKey'),
|
||||
loadAndDecrypt('claude_apiKey'),
|
||||
]);
|
||||
const state = get();
|
||||
set({
|
||||
providers: {
|
||||
...state.providers,
|
||||
openai: { ...state.providers.openai, apiKey: openaiKey },
|
||||
claude: { ...state.providers.claude, apiKey: claudeKey },
|
||||
},
|
||||
_keysHydrated: true,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'aive-ai-settings',
|
||||
partialize: (state) => ({
|
||||
providers: {
|
||||
ollama: { ...state.providers.ollama, apiKey: undefined },
|
||||
openai: { ...state.providers.openai, apiKey: '' },
|
||||
claude: { ...state.providers.claude, apiKey: '' },
|
||||
},
|
||||
defaultProvider: state.defaultProvider,
|
||||
customFillerWords: state.customFillerWords,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
useAIStore.getState().hydrateKeys();
|
||||
232
frontend/src/store/editorStore.ts
Normal file
232
frontend/src/store/editorStore.ts
Normal file
@ -0,0 +1,232 @@
|
||||
import { create } from 'zustand';
|
||||
import { temporal } from 'zundo';
|
||||
import type { Word, Segment, DeletedRange, TranscriptionResult } from '../types/project';
|
||||
|
||||
interface EditorState {
|
||||
videoPath: string | null;
|
||||
videoUrl: string | null;
|
||||
words: Word[];
|
||||
segments: Segment[];
|
||||
deletedRanges: DeletedRange[];
|
||||
language: string;
|
||||
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
|
||||
selectedWordIndices: number[];
|
||||
hoveredWordIndex: number | null;
|
||||
|
||||
isTranscribing: boolean;
|
||||
transcriptionProgress: number;
|
||||
isExporting: boolean;
|
||||
exportProgress: number;
|
||||
|
||||
backendUrl: string;
|
||||
}
|
||||
|
||||
interface EditorActions {
|
||||
setBackendUrl: (url: string) => void;
|
||||
loadVideo: (path: string) => void;
|
||||
setTranscription: (result: TranscriptionResult) => void;
|
||||
setCurrentTime: (time: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
setIsPlaying: (playing: boolean) => void;
|
||||
setSelectedWordIndices: (indices: number[]) => void;
|
||||
setHoveredWordIndex: (index: number | null) => void;
|
||||
deleteSelectedWords: () => void;
|
||||
deleteWordRange: (startIndex: number, endIndex: number) => void;
|
||||
restoreRange: (rangeId: string) => void;
|
||||
setTranscribing: (active: boolean, progress?: number) => void;
|
||||
setExporting: (active: boolean, progress?: number) => void;
|
||||
getKeepSegments: () => Array<{ start: number; end: number }>;
|
||||
getWordAtTime: (time: number) => number;
|
||||
loadProject: (projectData: any) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState: EditorState = {
|
||||
videoPath: null,
|
||||
videoUrl: null,
|
||||
words: [],
|
||||
segments: [],
|
||||
deletedRanges: [],
|
||||
language: '',
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
isPlaying: false,
|
||||
selectedWordIndices: [],
|
||||
hoveredWordIndex: null,
|
||||
isTranscribing: false,
|
||||
transcriptionProgress: 0,
|
||||
isExporting: false,
|
||||
exportProgress: 0,
|
||||
backendUrl: 'http://localhost:8642',
|
||||
};
|
||||
|
||||
let nextRangeId = 1;
|
||||
|
||||
export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
temporal(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setBackendUrl: (url) => set({ backendUrl: url }),
|
||||
|
||||
loadVideo: (path) => {
|
||||
const backend = get().backendUrl;
|
||||
const url = `${backend}/file?path=${encodeURIComponent(path)}`;
|
||||
set({
|
||||
...initialState,
|
||||
backendUrl: backend,
|
||||
videoPath: path,
|
||||
videoUrl: url,
|
||||
});
|
||||
},
|
||||
|
||||
setTranscription: (result) => {
|
||||
let globalIdx = 0;
|
||||
const annotatedSegments = result.segments.map((seg) => {
|
||||
const annotated = { ...seg, globalStartIndex: globalIdx };
|
||||
globalIdx += seg.words.length;
|
||||
return annotated;
|
||||
});
|
||||
set({
|
||||
words: result.words,
|
||||
segments: annotatedSegments,
|
||||
language: result.language,
|
||||
deletedRanges: [],
|
||||
selectedWordIndices: [],
|
||||
});
|
||||
},
|
||||
|
||||
setCurrentTime: (time) => set({ currentTime: time }),
|
||||
setDuration: (duration) => set({ duration }),
|
||||
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||
setSelectedWordIndices: (indices) => set({ selectedWordIndices: indices }),
|
||||
setHoveredWordIndex: (index) => set({ hoveredWordIndex: index }),
|
||||
|
||||
deleteSelectedWords: () => {
|
||||
const { selectedWordIndices, words, deletedRanges } = get();
|
||||
if (selectedWordIndices.length === 0) return;
|
||||
|
||||
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
||||
const startWord = words[sorted[0]];
|
||||
const endWord = words[sorted[sorted.length - 1]];
|
||||
|
||||
const newRange: DeletedRange = {
|
||||
id: `dr_${nextRangeId++}`,
|
||||
start: startWord.start,
|
||||
end: endWord.end,
|
||||
wordIndices: sorted,
|
||||
};
|
||||
|
||||
set({
|
||||
deletedRanges: [...deletedRanges, newRange],
|
||||
selectedWordIndices: [],
|
||||
});
|
||||
},
|
||||
|
||||
deleteWordRange: (startIndex, endIndex) => {
|
||||
const { words, deletedRanges } = get();
|
||||
const indices = [];
|
||||
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) });
|
||||
},
|
||||
|
||||
setTranscribing: (active, progress) =>
|
||||
set({
|
||||
isTranscribing: active,
|
||||
transcriptionProgress: progress ?? (active ? 0 : 100),
|
||||
}),
|
||||
|
||||
setExporting: (active, progress) =>
|
||||
set({
|
||||
isExporting: active,
|
||||
exportProgress: progress ?? (active ? 0 : 100),
|
||||
}),
|
||||
|
||||
getKeepSegments: () => {
|
||||
const { words, deletedRanges, duration } = get();
|
||||
if (words.length === 0) return [{ start: 0, end: duration }];
|
||||
|
||||
const deletedSet = new Set<number>();
|
||||
for (const range of deletedRanges) {
|
||||
for (const idx of range.wordIndices) deletedSet.add(idx);
|
||||
}
|
||||
|
||||
const segments: Array<{ start: number; end: number }> = [];
|
||||
let segStart: number | null = null;
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
if (!deletedSet.has(i)) {
|
||||
if (segStart === null) segStart = words[i].start;
|
||||
} else {
|
||||
if (segStart !== null) {
|
||||
segments.push({ start: segStart, end: words[i - 1].end });
|
||||
segStart = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (segStart !== null) {
|
||||
segments.push({ start: segStart, end: words[words.length - 1].end });
|
||||
}
|
||||
|
||||
return segments;
|
||||
},
|
||||
|
||||
getWordAtTime: (time) => {
|
||||
const { words } = get();
|
||||
let lo = 0;
|
||||
let hi = words.length - 1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (words[mid].end < time) lo = mid + 1;
|
||||
else if (words[mid].start > time) hi = mid - 1;
|
||||
else return mid;
|
||||
}
|
||||
return lo < words.length ? lo : words.length - 1;
|
||||
},
|
||||
|
||||
loadProject: (data) => {
|
||||
const backend = get().backendUrl;
|
||||
const url = `${backend}/file?path=${encodeURIComponent(data.videoPath)}`;
|
||||
|
||||
let globalIdx = 0;
|
||||
const annotatedSegments = (data.segments || []).map((seg: Segment) => {
|
||||
const annotated = { ...seg, globalStartIndex: globalIdx };
|
||||
globalIdx += seg.words.length;
|
||||
return annotated;
|
||||
});
|
||||
|
||||
set({
|
||||
...initialState,
|
||||
backendUrl: backend,
|
||||
videoPath: data.videoPath,
|
||||
videoUrl: url,
|
||||
words: data.words || [],
|
||||
segments: annotatedSegments,
|
||||
deletedRanges: data.deletedRanges || [],
|
||||
language: data.language || '',
|
||||
});
|
||||
},
|
||||
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
{ limit: 100 },
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user