Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 69 additions & 6 deletions frontend/scripts/smoke-clips-first-lifecycle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ const aiStoreSource = readSource('../src/store/aiStore.ts');

assert.match(appSource, /label=\{editorWorkflow === 'short' \? 'Export Video' : 'Export'\}/);
assert.match(appSource, /dataAction="full-video-export"/);
assert.match(appSource, /const resetClipWorkspaceForNewMedia = useCallback/);
assert.match(appSource, /useAIStore\.getState\(\)\.resetClipWorkspace\(\)/);
assert.match(appSource, /const resetMediaAIWorkspaceForNewMedia = useCallback/);
assert.match(appSource, /useAIStore\.getState\(\)\.resetMediaAIWorkspace\(\)/);
assert.match(appSource, /clearClipPresentationPreview\(\)/);
assert.match(appSource, /setSelectedWordIndices\(\[\]\)/);
assert.match(appSource, /<AIPanel key=\{workspaceRevision\}/);
Expand All @@ -29,13 +29,31 @@ assert.match(panelSource, /mode === 'clips' \? \(/);
assert.match(panelSource, /const secondaryToolsVisible/);
assert.match(panelSource, /getInitialClipWorkspaceStage\(clipDrafts, clipSuggestions\)/);

assert.match(aiStoreSource, /resetClipWorkspace: \(\) =>/);
assert.match(aiStoreSource, /resetClipWorkspaceState\(state\)/);
assert.match(aiStoreSource, /resetMediaAIWorkspace: \(\) =>/);
assert.match(aiStoreSource, /resetMediaAIWorkspaceState\(state\)/);
assert.match(aiStoreSource, /restoreMediaAIWorkspaceState\(state, workspace\)/);
assert.match(aiStoreSource, /clipWorkspaceEpoch: state\.clipWorkspaceEpoch \+ 1/);
assert.match(aiStoreSource, /fillerResult: null/);
assert.match(aiStoreSource, /fillerDecisions: \{\}/);
assert.match(aiStoreSource, /editPlanInstruction: ''/);
assert.match(aiStoreSource, /editPlanResult: null/);
assert.match(aiStoreSource, /editPlanDecisions: \{\}/);
assert.match(aiStoreSource, /clipSuggestions: \[\]/);
assert.match(aiStoreSource, /clipDrafts: \[\]/);
assert.match(aiStoreSource, /clipReviewDecisions: \{\}/);
assert.match(aiStoreSource, /providers: \{/);
assert.match(panelSource, /if \(!isCurrentClipWorkspace\(\)\) return;\s+setEditPlanResult\(data\);/);
assert.match(panelSource, /if \(!isCurrentClipWorkspace\(\)\) return;\s+setFillerResult\(data\);/);
assert.match(panelSource, /const result = await pollAIJob<unknown>[\s\S]*if \(!isCurrentClipWorkspace\(\)\) return;/);
assert.match(panelSource, /const cancelAIJob = useCallback\([\s\S]*if \(!isCurrentClipWorkspace\(\)\) return;/);
assert.match(panelSource, /finally \{\s+if \(isCurrentClipWorkspace\(\)\) setProcessing\(false\);/);

const openFileBody = appSource.match(/const handleOpenFile = async \(intent: WorkflowIntent = 'full-video'\) => \{([\s\S]*?)\n\s*\};\s*const handleBrowserFileChange/)?.[1] || '';
const browserChangeBody = appSource.match(/const handleBrowserFileChange = async \(e: React\.ChangeEvent<HTMLInputElement>\) => \{([\s\S]*?)\n\s*\};\s*const handleBrowserDrop/)?.[1] || '';
assert.match(openFileBody, /const path = await window\.electronAPI!\.openFile\(\);[\s\S]*if \(path\) \{[\s\S]*resetMediaAIWorkspaceForNewMedia\(\)/);
assert.match(browserChangeBody, /e\.target\.value = '';[\s\S]*if \(!file\) return;/);
assert.match(appSource, /const restoreProject = \(data: ReturnType<typeof parseProjectFile>\) => \{[\s\S]*loadProjectState\(data\)/);
assert.match(appSource, /useAIStore\.getState\(\)\.loadProjectAIState\(data\.aiWorkspace\)/);

function loadTsModule(relativePath) {
const source = readSource(relativePath);
Expand All @@ -50,7 +68,7 @@ function loadTsModule(relativePath) {
return module.exports;
}

const { resetClipWorkspaceState } = loadTsModule('../src/utils/clipWorkspace.ts');
const { resetMediaAIWorkspaceState, restoreMediaAIWorkspaceState } = loadTsModule('../src/utils/clipWorkspace.ts');
const { getProjectWorkflow } = loadTsModule('../src/utils/editorTask.ts');

const suggestion = {
Expand All @@ -61,17 +79,35 @@ const suggestion = {
endTime: 18,
reason: 'Clear hook',
};
const fillerResult = {
fillerWords: [{ index: 1, word: 'um', start: 1, end: 1.2, reason: 'Repeated filler' }],
};
const editPlanResult = {
summary: 'Tighten the opening',
suggestions: [{ id: 'edit-1', startWordIndex: 0, endWordIndex: 2, startTime: 0, endTime: 4, reason: 'Tighter hook' }],
};
const providerState = {
providers: { ollama: { provider: 'ollama', model: 'llama3' } },
defaultProvider: 'ollama',
customFillerWords: 'okay',
_keysHydrated: true,
fillerResult,
fillerDecisions: { 1: 'rejected' },
editPlanInstruction: 'Tighten the opening',
editPlanResult,
editPlanDecisions: { 'edit-1': 'accepted' },
clipSuggestions: [suggestion],
clipDrafts: [{ ...suggestion, id: 'clip-1', status: 'draft' }],
clipReviewDecisions: { 'clip-0-4': 'approved' },
isProcessing: true,
processingMessage: 'Finding clips...',
};
const resetState = resetClipWorkspaceState(providerState);
const resetState = resetMediaAIWorkspaceState(providerState);
assert.equal(resetState.fillerResult, null);
assert.deepEqual(resetState.fillerDecisions, {});
assert.equal(resetState.editPlanInstruction, '');
assert.equal(resetState.editPlanResult, null);
assert.deepEqual(resetState.editPlanDecisions, {});
assert.deepEqual(resetState.clipSuggestions, []);
assert.deepEqual(resetState.clipDrafts, []);
assert.deepEqual(resetState.clipReviewDecisions, {});
Expand All @@ -80,6 +116,33 @@ assert.equal(resetState.processingMessage, '');
assert.equal(resetState.providers, providerState.providers);
assert.equal(resetState.defaultProvider, providerState.defaultProvider);
assert.equal(resetState.customFillerWords, providerState.customFillerWords);
assert.equal(resetState._keysHydrated, providerState._keysHydrated);

const projectWorkspace = {
customFillerWords: 'project filler',
fillerResult,
fillerDecisions: { 1: 'accepted' },
editPlanInstruction: 'Keep the strongest explanation',
editPlanResult,
editPlanDecisions: { 'edit-1': 'rejected' },
clipSuggestions: [suggestion],
clipDrafts: [{ ...suggestion, id: 'project-clip-1', status: 'draft' }],
clipReviewDecisions: { 'clip-0-4': 'skipped' },
};
const restoredState = restoreMediaAIWorkspaceState(resetState, projectWorkspace);
assert.deepEqual(restoredState.fillerResult, projectWorkspace.fillerResult);
assert.deepEqual(restoredState.fillerDecisions, projectWorkspace.fillerDecisions);
assert.equal(restoredState.editPlanInstruction, projectWorkspace.editPlanInstruction);
assert.deepEqual(restoredState.editPlanResult, projectWorkspace.editPlanResult);
assert.deepEqual(restoredState.editPlanDecisions, projectWorkspace.editPlanDecisions);
assert.deepEqual(restoredState.clipSuggestions, projectWorkspace.clipSuggestions);
assert.deepEqual(restoredState.clipDrafts, projectWorkspace.clipDrafts);
assert.deepEqual(restoredState.clipReviewDecisions, projectWorkspace.clipReviewDecisions);
assert.equal(restoredState.customFillerWords, projectWorkspace.customFillerWords);
assert.equal(restoredState.providers, providerState.providers);
assert.equal(restoredState.defaultProvider, providerState.defaultProvider);
assert.equal(restoredState.isProcessing, false);
assert.equal(restoredState.processingMessage, '');

assert.equal(getProjectWorkflow({ clipSuggestions: [suggestion] }), 'short');
assert.equal(getProjectWorkflow({ clipDrafts: [{ ...suggestion, status: 'draft' }] }), 'short');
Expand Down
2 changes: 1 addition & 1 deletion frontend/scripts/smoke-errors-accessibility.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ assert.match(openFileBody, /if\s*\(path\)\s*\{[\s\S]*setEditorWorkflow\(intent\)
assert.doesNotMatch(openFileBody.split("if (IS_ELECTRON)")[0], /setEditorWorkflow\(/);
assert.match(browserChangeBody, /if\s*\(!file\)\s*return;[\s\S]*await uploadBrowserFile\(file, browserWorkflowIntent\)/);
assert.doesNotMatch(browserChangeBody.split('if (!file) return;')[0], /setEditorWorkflow\(/);
assert.match(app, /const data = \(await res\.json\(\)\)[\s\S]*resetClipWorkspaceForNewMedia\(\)/);
assert.match(app, /const data = \(await res\.json\(\)\)[\s\S]*resetMediaAIWorkspaceForNewMedia\(\)/);
assert.match(app, /Autosaved work found/);
assert.match(app, /Restore autosave/);
assert.match(app, /Start new transcription/);
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,8 @@ export default function App() {
setActivePanel(workflow === 'short' ? 'ai' : null);
};

const resetClipWorkspaceForNewMedia = useCallback(() => {
useAIStore.getState().resetClipWorkspace();
const resetMediaAIWorkspaceForNewMedia = useCallback(() => {
useAIStore.getState().resetMediaAIWorkspace();
const editorState = useEditorStore.getState();
editorState.clearClipPresentationPreview();
editorState.setSelectedWordIndices([]);
Expand Down Expand Up @@ -358,7 +358,7 @@ export default function App() {
if (IS_ELECTRON) {
const path = await window.electronAPI!.openFile();
if (path) {
resetClipWorkspaceForNewMedia();
resetMediaAIWorkspaceForNewMedia();
setEditorWorkflow(intent);
applyWorkflowIntent(intent);
const restored = await tryRestoreAutosave(path);
Expand Down Expand Up @@ -413,7 +413,7 @@ export default function App() {
}

const data = (await res.json()) as { path: string; filename: string; size: number };
resetClipWorkspaceForNewMedia();
resetMediaAIWorkspaceForNewMedia();
setEditorWorkflow(intent);
applyWorkflowIntent(intent);
loadVideo(data.path);
Expand Down
17 changes: 12 additions & 5 deletions frontend/src/components/AIPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -519,16 +519,18 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) {
'Edit planning',
{ label: 'Edit planning' },
);
if (!isCurrentClipWorkspace()) return;
setEditPlanResult(data);
} catch (err) {
console.error(err);
setCreatorNotice({ ...getCreatorErrorPresentation('ai-action', err), onDismiss: () => setCreatorNotice(null) });
} finally {
setProcessing(false);
if (isCurrentClipWorkspace()) setProcessing(false);
}
}, [
defaultProvider,
editPlanInstruction,
isCurrentClipWorkspace,
providers,
setEditPlanResult,
setProcessing,
Expand Down Expand Up @@ -565,6 +567,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) {
'AI Director',
{ label: 'AI Director' },
);
if (!isCurrentClipWorkspace()) return;
setEditPlanResult(data);
if (data.directorClip) {
if (!isCurrentClipWorkspace()) return;
Expand Down Expand Up @@ -638,12 +641,13 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) {
const cancelAIJob = useCallback(async () => {
if (!activeAIJob || !['queued', 'running'].includes(activeAIJob.status)) return;
const res = await fetch(`${backendUrl}/jobs/${activeAIJob.id}/cancel`, { method: 'POST' });
if (!isCurrentClipWorkspace()) return;
if (res.ok) {
const job = (await res.json()) as AIJob<unknown>;
setActiveAIJob({ ...job, label: activeAIJob.label, draftId: activeAIJob.draftId });
}
setProcessing(false);
}, [activeAIJob, backendUrl, setProcessing]);
}, [activeAIJob, backendUrl, isCurrentClipWorkspace, setProcessing]);

const detectFillers = useCallback(async () => {
if (words.length === 0) return;
Expand All @@ -665,14 +669,15 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) {
'Filler detection',
{ label: 'Filler detection' },
);
if (!isCurrentClipWorkspace()) return;
setFillerResult(data);
} catch (err) {
console.error(err);
setCreatorNotice({ ...getCreatorErrorPresentation('ai-action', err), onDismiss: () => setCreatorNotice(null) });
} finally {
setProcessing(false);
if (isCurrentClipWorkspace()) setProcessing(false);
}
}, [words, defaultProvider, providers, customFillerWords, setProcessing, setFillerResult, startAIJob]);
}, [words, defaultProvider, providers, customFillerWords, isCurrentClipWorkspace, setProcessing, setFillerResult, startAIJob]);

const createClips = useCallback(async () => {
if (words.length === 0) return;
Expand Down Expand Up @@ -1423,6 +1428,7 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) {
const { job_id: jobId } = await retryRes.json();
const context = { label: activeAIJob.label, draftId: activeAIJob.draftId };
const result = await pollAIJob<unknown>(jobId, activeAIJob.label, context);
if (!isCurrentClipWorkspace()) return;

if (activeAIJob.kind === 'ai:filler-removal') {
setFillerResult(result as FillerWordResult);
Expand All @@ -1441,13 +1447,14 @@ export default function AIPanel({ mode = 'general' }: { mode?: AIPanelMode }) {
console.error(err);
setCreatorNotice({ ...getCreatorErrorPresentation('ai-action', err), onDismiss: () => setCreatorNotice(null) });
} finally {
setProcessing(false);
if (isCurrentClipWorkspace()) setProcessing(false);
}
}, [
activeAIJob,
applyClipDiscoveryResult,
backendUrl,
clipDrafts,
isCurrentClipWorkspace,
pollAIJob,
setEditPlanResult,
setFillerResult,
Expand Down
28 changes: 11 additions & 17 deletions frontend/src/store/aiStore.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { AIProvider, AIProviderConfig, FillerWordResult, ClipSuggestion, ClipDraft, ProjectAIWorkspace, FillerReviewDecision, EditPlanResult, EditPlanReviewDecision, ClipReviewDecision } from '../types/project';
import { normalizeClipReviewDecisions, resetClipWorkspaceState } from '../utils/clipWorkspace';
import {
normalizeClipReviewDecisions,
resetMediaAIWorkspaceState,
restoreMediaAIWorkspaceState,
} from '../utils/clipWorkspace';

const ENCRYPTED_KEY_PREFIX = 'scriptcut_enc_';
const LEGACY_ENCRYPTED_KEY_PREFIX = 'aive_enc_';
Expand Down Expand Up @@ -65,7 +69,7 @@ interface AIActions {
| Record<string, ClipReviewDecision>
| ((current: Record<string, ClipReviewDecision>) => Record<string, ClipReviewDecision>),
) => void;
resetClipWorkspace: () => void;
resetMediaAIWorkspace: () => void;
setProcessing: (active: boolean, message?: string) => void;
loadProjectAIState: (workspace?: ProjectAIWorkspace) => void;
hydrateKeys: () => Promise<void>;
Expand Down Expand Up @@ -173,30 +177,20 @@ export const useAIStore = create<AIState & AIActions>()(
return { clipReviewDecisions: normalizeClipReviewDecisions(next) };
}),

resetClipWorkspace: () =>
resetMediaAIWorkspace: () =>
set((state) => ({
...resetClipWorkspaceState(state),
...resetMediaAIWorkspaceState(state),
clipWorkspaceEpoch: state.clipWorkspaceEpoch + 1,
})),

setProcessing: (active, message) =>
set({ isProcessing: active, processingMessage: message ?? '' }),

loadProjectAIState: (workspace) =>
set({
customFillerWords: workspace?.customFillerWords ?? get().customFillerWords,
fillerResult: workspace?.fillerResult ?? null,
fillerDecisions: workspace?.fillerDecisions ?? {},
editPlanInstruction: workspace?.editPlanInstruction ?? '',
editPlanResult: workspace?.editPlanResult ?? null,
editPlanDecisions: workspace?.editPlanDecisions ?? {},
clipSuggestions: workspace?.clipSuggestions ?? [],
clipDrafts: workspace?.clipDrafts ?? [],
clipReviewDecisions: normalizeClipReviewDecisions(workspace?.clipReviewDecisions),
set((state) => ({
...restoreMediaAIWorkspaceState(state, workspace),
clipWorkspaceEpoch: get().clipWorkspaceEpoch + 1,
isProcessing: false,
processingMessage: '',
}),
})),

hydrateKeys: async () => {
const [openaiKey, claudeKey, routerKey] = await Promise.all([
Expand Down
41 changes: 39 additions & 2 deletions frontend/src/utils/clipWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,36 @@ import type {
ClipDraftStatus,
ClipReviewDecision,
ClipSuggestion,
EditPlanResult,
EditPlanReviewDecision,
FillerReviewDecision,
FillerWordResult,
ProjectAIWorkspace,
} from '../types/project';

export type ClipWorkspaceStage = 'find' | 'review' | 'prepare' | 'export';

export function resetClipWorkspaceState<T extends {
type MediaAIWorkspaceState = {
fillerResult: FillerWordResult | null;
fillerDecisions: Record<number, FillerReviewDecision>;
editPlanInstruction: string;
editPlanResult: EditPlanResult | null;
editPlanDecisions: Record<string, EditPlanReviewDecision>;
clipSuggestions: ClipSuggestion[];
clipDrafts: ClipDraft[];
clipReviewDecisions: Record<string, ClipReviewDecision>;
isProcessing: boolean;
processingMessage: string;
}>(state: T): T {
};

export function resetMediaAIWorkspaceState<T extends MediaAIWorkspaceState>(state: T): T {
return {
...state,
fillerResult: null,
fillerDecisions: {},
editPlanInstruction: '',
editPlanResult: null,
editPlanDecisions: {},
clipSuggestions: [],
clipDrafts: [],
clipReviewDecisions: {},
Expand All @@ -24,6 +41,26 @@ export function resetClipWorkspaceState<T extends {
};
}

export function restoreMediaAIWorkspaceState<T extends MediaAIWorkspaceState & { customFillerWords: string }>(
state: T,
workspace?: ProjectAIWorkspace,
): T {
return {
...state,
customFillerWords: workspace?.customFillerWords ?? state.customFillerWords,
fillerResult: workspace?.fillerResult ?? null,
fillerDecisions: workspace?.fillerDecisions ?? {},
editPlanInstruction: workspace?.editPlanInstruction ?? '',
editPlanResult: workspace?.editPlanResult ?? null,
editPlanDecisions: workspace?.editPlanDecisions ?? {},
clipSuggestions: workspace?.clipSuggestions ?? [],
clipDrafts: workspace?.clipDrafts ?? [],
clipReviewDecisions: normalizeClipReviewDecisions(workspace?.clipReviewDecisions),
isProcessing: false,
processingMessage: '',
};
}

export function readClipDiscoveryResult(result: unknown): {
clips: ClipSuggestion[];
requestedCount: number;
Expand Down