From d75cf3bb1e1a76138edac556575ca07f542d115d Mon Sep 17 00:00:00 2001 From: FernandoM33 Date: Sun, 23 Aug 2026 22:14:37 -0700 Subject: [PATCH 1/2] feat: make clips a first-class workspace --- frontend/package.json | 1 + .../scripts/smoke-clips-first-lifecycle.mjs | 83 ++++++++++++++++ .../scripts/smoke-create-clips-workspace.mjs | 2 +- frontend/scripts/smoke-editor-frame.mjs | 17 +++- .../scripts/smoke-errors-accessibility.mjs | 3 +- frontend/src/App.tsx | 45 ++++++--- frontend/src/components/AIPanel.tsx | 97 +++++++++++++++---- frontend/src/hooks/useKeyboardShortcuts.ts | 5 +- frontend/src/store/aiStore.ts | 12 ++- frontend/src/utils/clipWorkspace.ts | 13 +++ frontend/src/utils/editorTask.ts | 38 +++++++- 11 files changed, 269 insertions(+), 47 deletions(-) create mode 100644 frontend/scripts/smoke-clips-first-lifecycle.mjs diff --git a/frontend/package.json b/frontend/package.json index 46e172d..c81cb8d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "test:transcription-ux": "node scripts/smoke-transcription-ux.mjs", "test:editor-frame": "node scripts/smoke-editor-frame.mjs", "test:create-clips-workspace": "node scripts/smoke-create-clips-workspace.mjs", + "test:clips-first-lifecycle": "node scripts/smoke-clips-first-lifecycle.mjs", "test:settings-export-ux": "node scripts/smoke-settings-export-ux.mjs", "test:transcript-selection": "node scripts/smoke-transcript-selection.mjs", "test:transcript-search": "node scripts/smoke-transcript-search.mjs", diff --git a/frontend/scripts/smoke-clips-first-lifecycle.mjs b/frontend/scripts/smoke-clips-first-lifecycle.mjs new file mode 100644 index 0000000..5108f11 --- /dev/null +++ b/frontend/scripts/smoke-clips-first-lifecycle.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const readSource = (relativePath) => readFileSync(resolve(__dirname, relativePath), 'utf8'); + +const appSource = readSource('../src/App.tsx'); +const panelSource = readSource('../src/components/AIPanel.tsx'); +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, /clearClipPresentationPreview\(\)/); +assert.match(appSource, /setSelectedWordIndices\(\[\]\)/); +assert.match(appSource, //); +assert.match(aiStoreSource, /resetClipWorkspaceState\(state\)/); +assert.match(aiStoreSource, /clipWorkspaceEpoch: state\.clipWorkspaceEpoch \+ 1/); +assert.match(aiStoreSource, /clipSuggestions: \[\]/); +assert.match(aiStoreSource, /clipDrafts: \[\]/); +assert.match(aiStoreSource, /clipReviewDecisions: \{\}/); +assert.match(aiStoreSource, /providers: \{/); + +function loadTsModule(relativePath) { + const source = readSource(relativePath); + const compiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + }); + const module = { exports: {} }; + new Function('exports', 'module', 'require', compiled.outputText)(module.exports, module, require); + return module.exports; +} + +const { resetClipWorkspaceState } = loadTsModule('../src/utils/clipWorkspace.ts'); +const { getProjectWorkflow } = loadTsModule('../src/utils/editorTask.ts'); + +const suggestion = { + title: 'Opening hook', + startWordIndex: 0, + endWordIndex: 4, + startTime: 0, + endTime: 18, + reason: 'Clear hook', +}; +const providerState = { + providers: { ollama: { provider: 'ollama', model: 'llama3' } }, + defaultProvider: 'ollama', + customFillerWords: 'okay', + clipSuggestions: [suggestion], + clipDrafts: [{ ...suggestion, id: 'clip-1', status: 'draft' }], + clipReviewDecisions: { 'clip-0-4': 'approved' }, +}; +const resetState = resetClipWorkspaceState(providerState); +assert.deepEqual(resetState.clipSuggestions, []); +assert.deepEqual(resetState.clipDrafts, []); +assert.deepEqual(resetState.clipReviewDecisions, {}); +assert.equal(resetState.providers, providerState.providers); +assert.equal(resetState.defaultProvider, providerState.defaultProvider); +assert.equal(resetState.customFillerWords, providerState.customFillerWords); + +assert.equal(getProjectWorkflow({ clipSuggestions: [suggestion] }), 'short'); +assert.equal(getProjectWorkflow({ clipDrafts: [{ ...suggestion, status: 'draft' }] }), 'short'); +assert.equal(getProjectWorkflow({ clipReviewDecisions: { 'clip-0-4': 'approved' } }), 'project'); +assert.equal(getProjectWorkflow({}), 'project'); diff --git a/frontend/scripts/smoke-create-clips-workspace.mjs b/frontend/scripts/smoke-create-clips-workspace.mjs index 4230c38..5365a48 100644 --- a/frontend/scripts/smoke-create-clips-workspace.mjs +++ b/frontend/scripts/smoke-create-clips-workspace.mjs @@ -91,7 +91,7 @@ assert.doesNotMatch(panelSource, /Shorts Queue/); assert.doesNotMatch(panelSource, /AI Suggestions/); assert.doesNotMatch(panelSource, /handleExportSuggestedClip/); assert.match(appSource, /label=\{editorWorkflow === 'short' \? 'Create Clips' : 'AI'\}/); -assert.match(appSource, /\('full-video'\)/); assert.match(appSource, /setEditorWorkflow\(intent\)/); -assert.match(appSource, /setEditorWorkflow\('project'\)/); +assert.match(appSource, /const restoreProject =/); +assert.match(appSource, /getProjectWorkflow\(data\.aiWorkspace\)/); assert.doesNotMatch(handleOpenFileBody.split('if (IS_ELECTRON)')[0], /setEditorWorkflow\(/); assert.match( handleOpenFileBody, - /if \(path\) \{\s*setEditorWorkflow\(intent\);\s*applyWorkflowIntent\(intent\);\s*const restored = await tryRestoreAutosave/, + /if \(path\) \{[\s\S]*?setEditorWorkflow\(intent\);\s*applyWorkflowIntent\(intent\);\s*const restored = await tryRestoreAutosave/, ); assert.doesNotMatch(handleBrowserFileChangeBody.split('if (!file) return;')[0], /setEditorWorkflow\(/); -assert.match(handleBrowserFileChangeBody, /if \(!file\) return;\s*setEditorWorkflow\(browserWorkflowIntent\);\s*await uploadBrowserFile/); -assert.match(handleBrowserDropBody, /setEditorWorkflow\('full-video'\);[\s\S]*uploadBrowserFile\(file, 'full-video'\)/); +assert.match(handleBrowserFileChangeBody, /if \(!file\) return;\s*await uploadBrowserFile\(file, browserWorkflowIntent\)/); +assert.match(handleBrowserDropBody, /if \(!file\) return;\s*await uploadBrowserFile\(file, 'full-video'\)/); assert.match(appSource, /\) => \{([\s\S]*?)\n \};\n\n const handleBrowserDrop/)?.[1] || ''; 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]*setEditorWorkflow\(browserWorkflowIntent\)[\s\S]*uploadBrowserFile/); +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, /Autosaved work found/); assert.match(app, /Restore autosave/); assert.match(app, /Start new transcription/); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e742056..713f5e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -52,6 +52,7 @@ import { getCoreReadiness } from './utils/homeReadiness'; import { getEditorTaskPresentation, getPostTranscriptionPanel, + getProjectWorkflow, type EditorPanel, type EditorWorkflow, } from './utils/editorTask'; @@ -90,6 +91,7 @@ export default function App() { const [activePanel, setActivePanel] = useState(null); const [editorWorkflow, setEditorWorkflow] = useState('full-video'); + const [workspaceRevision, setWorkspaceRevision] = useState(0); const [showMoreMenu, setShowMoreMenu] = useState(false); const [transcriptionEngine, setTranscriptionEngine] = useState('auto'); const [transcriptionModel, setTranscriptionModel] = useState(AUTOMATIC_TRANSCRIPTION_MODEL); @@ -214,6 +216,22 @@ export default function App() { refreshRecentProjects(); }; + const restoreProject = (data: ReturnType) => { + loadProjectState(data); + setWorkspaceRevision((current) => current + 1); + const workflow = getProjectWorkflow(data.aiWorkspace); + setEditorWorkflow(workflow); + setActivePanel(workflow === 'short' ? 'ai' : null); + }; + + const resetClipWorkspaceForNewMedia = useCallback(() => { + useAIStore.getState().resetClipWorkspace(); + const editorState = useEditorStore.getState(); + editorState.clearClipPresentationPreview(); + editorState.setSelectedWordIndices([]); + setWorkspaceRevision((current) => current + 1); + }, []); + const handleLoadProject = async () => { if (!IS_ELECTRON) return; setCreatorNotice(null); @@ -222,8 +240,7 @@ export default function App() { if (!projectPath) return; const content = await window.electronAPI!.readProjectFile(projectPath); const data = parseProjectFile(content); - loadProjectState(data); - setEditorWorkflow('project'); + restoreProject(data); rememberProject(projectPath, data, 'project'); } catch (err) { console.error('Failed to load project:', err); @@ -243,8 +260,7 @@ export default function App() { const path = getAutosaveSnapshotPaths(candidate.videoPath)[snapshotIndex] || candidate.path; const content = await window.electronAPI!.readProjectFile(path); const data = parseProjectFile(content); - loadProjectState(data); - setEditorWorkflow('project'); + restoreProject(data); rememberProject(path, data, 'autosave'); } catch (err) { console.error('Failed to recover autosave:', err); @@ -286,8 +302,7 @@ export default function App() { try { const content = await window.electronAPI!.readProjectFile(project.path); const data = parseProjectFile(content); - loadProjectState(data); - setEditorWorkflow('project'); + restoreProject(data); rememberProject(project.path, data, project.source); } catch (err) { removeRecentProject(project.path); @@ -343,6 +358,7 @@ export default function App() { if (IS_ELECTRON) { const path = await window.electronAPI!.openFile(); if (path) { + resetClipWorkspaceForNewMedia(); setEditorWorkflow(intent); applyWorkflowIntent(intent); const restored = await tryRestoreAutosave(path); @@ -352,7 +368,6 @@ export default function App() { await transcribeVideo(path, intent); } } else { - applyWorkflowIntent(intent); setBrowserWorkflowIntent(intent); fileInputRef.current?.click(); } @@ -362,7 +377,6 @@ export default function App() { const file = e.target.files?.[0]; e.target.value = ''; if (!file) return; - setEditorWorkflow(browserWorkflowIntent); await uploadBrowserFile(file, browserWorkflowIntent); }; @@ -370,8 +384,6 @@ export default function App() { e.preventDefault(); const file = e.dataTransfer.files?.[0]; if (!file) return; - setEditorWorkflow('full-video'); - applyWorkflowIntent('full-video'); await uploadBrowserFile(file, 'full-video'); }; @@ -401,6 +413,9 @@ export default function App() { } const data = (await res.json()) as { path: string; filename: string; size: number }; + resetClipWorkspaceForNewMedia(); + setEditorWorkflow(intent); + applyWorkflowIntent(intent); loadVideo(data.path); await transcribeVideo(data.path, intent); } catch (err) { @@ -425,7 +440,7 @@ export default function App() { }); if (!shouldRestore) return false; - loadProjectState(data); + restoreProject(data); return true; } catch { // Try the next autosave naming convention. @@ -702,12 +717,13 @@ export default function App() { /> } - label="Export" + label={editorWorkflow === 'short' ? 'Export Video' : 'Export'} active={activePanel === 'export'} onClick={() => togglePanel('export')} disabled={words.length === 0} controls="editor-side-panel" expanded={activePanel === 'export'} + dataAction="full-video-export" />
+ ) : ( + <> + setActiveTab('edit')} + icon={} + label="AI Editor" + /> + setActiveTab('filler')} + icon={} + label="Filler Words" + /> + + )} {mode === 'general' && ( )}
+ {secondaryToolsVisible && ( +
+ setActiveTab('edit')} + icon={} + label="AI Editor" + /> + setActiveTab('filler')} + icon={} + label="Filler Words" + /> +
+ )}
{creatorNotice && } diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts index 35eb4ff..3b2d696 100644 --- a/frontend/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -128,7 +128,10 @@ export function useKeyboardShortcuts() { case e.key === 'e' && (e.ctrlKey || e.metaKey): { e.preventDefault(); // Trigger export panel via DOM click - const exportBtn = document.querySelector('[title="Export"]') as HTMLButtonElement; + const exportBtn = ( + document.querySelector('[data-export-action="full-video-export"]') || + document.querySelector('[title="Export"]') + ) as HTMLButtonElement; if (exportBtn) exportBtn.click(); return; } diff --git a/frontend/src/store/aiStore.ts b/frontend/src/store/aiStore.ts index 6567980..d9b4361 100644 --- a/frontend/src/store/aiStore.ts +++ b/frontend/src/store/aiStore.ts @@ -1,7 +1,7 @@ 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 } from '../utils/clipWorkspace'; +import { normalizeClipReviewDecisions, resetClipWorkspaceState } from '../utils/clipWorkspace'; const ENCRYPTED_KEY_PREFIX = 'scriptcut_enc_'; const LEGACY_ENCRYPTED_KEY_PREFIX = 'aive_enc_'; @@ -35,6 +35,7 @@ interface AIState { clipSuggestions: ClipSuggestion[]; clipDrafts: ClipDraft[]; clipReviewDecisions: Record; + clipWorkspaceEpoch: number; isProcessing: boolean; processingMessage: string; _keysHydrated: boolean; @@ -64,6 +65,7 @@ interface AIActions { | Record | ((current: Record) => Record), ) => void; + resetClipWorkspace: () => void; setProcessing: (active: boolean, message?: string) => void; loadProjectAIState: (workspace?: ProjectAIWorkspace) => void; hydrateKeys: () => Promise; @@ -115,6 +117,7 @@ export const useAIStore = create()( clipSuggestions: [], clipDrafts: [], clipReviewDecisions: {}, + clipWorkspaceEpoch: 0, isProcessing: false, processingMessage: '', _keysHydrated: false, @@ -170,6 +173,12 @@ export const useAIStore = create()( return { clipReviewDecisions: normalizeClipReviewDecisions(next) }; }), + resetClipWorkspace: () => + set((state) => ({ + ...resetClipWorkspaceState(state), + clipWorkspaceEpoch: state.clipWorkspaceEpoch + 1, + })), + setProcessing: (active, message) => set({ isProcessing: active, processingMessage: message ?? '' }), @@ -184,6 +193,7 @@ export const useAIStore = create()( clipSuggestions: workspace?.clipSuggestions ?? [], clipDrafts: workspace?.clipDrafts ?? [], clipReviewDecisions: normalizeClipReviewDecisions(workspace?.clipReviewDecisions), + clipWorkspaceEpoch: get().clipWorkspaceEpoch + 1, isProcessing: false, processingMessage: '', }), diff --git a/frontend/src/utils/clipWorkspace.ts b/frontend/src/utils/clipWorkspace.ts index ccbb4ab..00a4b3f 100644 --- a/frontend/src/utils/clipWorkspace.ts +++ b/frontend/src/utils/clipWorkspace.ts @@ -7,6 +7,19 @@ import type { export type ClipWorkspaceStage = 'find' | 'review' | 'prepare' | 'export'; +export function resetClipWorkspaceState; +}>(state: T): T { + return { + ...state, + clipSuggestions: [], + clipDrafts: [], + clipReviewDecisions: {}, + }; +} + export function readClipDiscoveryResult(result: unknown): { clips: ClipSuggestion[]; requestedCount: number; diff --git a/frontend/src/utils/editorTask.ts b/frontend/src/utils/editorTask.ts index 382f63f..2fe2b92 100644 --- a/frontend/src/utils/editorTask.ts +++ b/frontend/src/utils/editorTask.ts @@ -1,3 +1,5 @@ +import type { ProjectAIWorkspace } from '../types/project'; + export type EditorWorkflow = 'full-video' | 'short' | 'project'; export type EditorPanel = 'ai' | 'export' | 'settings' | null; @@ -22,6 +24,26 @@ export function getPostTranscriptionPanel(workflow: EditorWorkflow | null | unde return workflow === 'short' ? 'ai' : null; } +export function getProjectWorkflow(workspace?: ProjectAIWorkspace): EditorWorkflow { + const clipSuggestions = Array.isArray(workspace?.clipSuggestions) ? workspace.clipSuggestions : []; + const clipDrafts = Array.isArray(workspace?.clipDrafts) ? workspace.clipDrafts : []; + + const restoredClipKeys = new Set( + [...clipSuggestions, ...clipDrafts].map( + (clip) => `clip-${clip.startWordIndex}-${clip.endWordIndex}`, + ), + ); + const decisions = workspace?.clipReviewDecisions; + const hasRelevantReviewDecision = Boolean( + decisions && + Object.keys(decisions).some((key) => restoredClipKeys.has(key)) + ); + + return clipSuggestions.length > 0 || clipDrafts.length > 0 || hasRelevantReviewDecision + ? 'short' + : 'project'; +} + export function getEditorTaskPresentation(input: EditorTaskInput): EditorTaskPresentation { const workflowLabel = getWorkflowLabel(input.workflow); @@ -44,13 +66,19 @@ export function getEditorTaskPresentation(input: EditorTaskInput): EditorTaskPre } if (input.activePanel === 'ai') { + if (input.workflow === 'short') { + return { + workflowLabel, + title: 'Create Clips', + description: 'Find, review, prepare, and export moments from your recording.', + status: input.wordCount > 0 ? 'Ready to find' : 'Waiting', + }; + } + return { workflowLabel, - title: input.workflow === 'short' ? 'Create Clips' : 'AI tools', - description: - input.workflow === 'short' - ? 'Find, review, prepare, and export moments from your recording.' - : 'Optional assistance for edits, filler words, and clips.', + title: 'AI tools', + description: 'Optional assistance for edits, filler words, and clips.', status: 'Optional', }; } From 0ea176056e57e7158c4c218e681076c9b9f55e78 Mon Sep 17 00:00:00 2001 From: FernandoM33 Date: Sun, 23 Aug 2026 22:46:23 -0700 Subject: [PATCH 2/2] fix: reset clip processing state with media workspace --- frontend/scripts/smoke-clips-first-lifecycle.mjs | 4 ++++ frontend/src/utils/clipWorkspace.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/frontend/scripts/smoke-clips-first-lifecycle.mjs b/frontend/scripts/smoke-clips-first-lifecycle.mjs index 5108f11..820d726 100644 --- a/frontend/scripts/smoke-clips-first-lifecycle.mjs +++ b/frontend/scripts/smoke-clips-first-lifecycle.mjs @@ -68,11 +68,15 @@ const providerState = { clipSuggestions: [suggestion], clipDrafts: [{ ...suggestion, id: 'clip-1', status: 'draft' }], clipReviewDecisions: { 'clip-0-4': 'approved' }, + isProcessing: true, + processingMessage: 'Finding clips...', }; const resetState = resetClipWorkspaceState(providerState); assert.deepEqual(resetState.clipSuggestions, []); assert.deepEqual(resetState.clipDrafts, []); assert.deepEqual(resetState.clipReviewDecisions, {}); +assert.equal(resetState.isProcessing, false); +assert.equal(resetState.processingMessage, ''); assert.equal(resetState.providers, providerState.providers); assert.equal(resetState.defaultProvider, providerState.defaultProvider); assert.equal(resetState.customFillerWords, providerState.customFillerWords); diff --git a/frontend/src/utils/clipWorkspace.ts b/frontend/src/utils/clipWorkspace.ts index 00a4b3f..3da12a8 100644 --- a/frontend/src/utils/clipWorkspace.ts +++ b/frontend/src/utils/clipWorkspace.ts @@ -11,12 +11,16 @@ export function resetClipWorkspaceState; + isProcessing: boolean; + processingMessage: string; }>(state: T): T { return { ...state, clipSuggestions: [], clipDrafts: [], clipReviewDecisions: {}, + isProcessing: false, + processingMessage: '', }; }