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
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
87 changes: 87 additions & 0 deletions frontend/scripts/smoke-clips-first-lifecycle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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, /<AIPanel key=\{workspaceRevision\}/);
assert.match(appSource, /restoreProject\(data\)/);
assert.match(appSource, /getProjectWorkflow\(data\.aiWorkspace\)/);

assert.match(panelSource, /label="Create Clips"/);
assert.match(panelSource, /More AI tools/);
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, /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' },
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);

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');
2 changes: 1 addition & 1 deletion frontend/scripts/smoke-create-clips-workspace.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<AIPanel mode=\{editorWorkflow === 'short' \? 'clips' : 'general'\}/);
assert.match(appSource, /<AIPanel (?:key=\{workspaceRevision\} )?mode=\{editorWorkflow === 'short' \? 'clips' : 'general'\}/);

const compiled = ts.transpileModule(workspaceSource, {
compilerOptions: {
Expand Down
17 changes: 13 additions & 4 deletions frontend/scripts/smoke-editor-frame.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,16 @@ const handleBrowserDropBody = appSource.match(

assert.match(appSource, /useState<EditorWorkflow>\('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, /<EditorTaskHeader presentation=\{taskPresentation\}/);
assert.match(appSource, /getEditorTaskPresentation\(/);
assert.match(appSource, /aria-controls="editor-side-panel"/);
Expand Down Expand Up @@ -59,6 +60,7 @@ function loadTsModule(relativePath) {

const {
getEditorTaskPresentation,
getProjectWorkflow,
getPostTranscriptionPanel,
} = loadTsModule('../src/utils/editorTask.ts');

Expand Down Expand Up @@ -95,6 +97,7 @@ assert.equal(clipsReady.workflowLabel, 'Create Clips');
assert.equal(clipsReady.title, 'Transcript ready');
assert.match(clipsReady.description, /AI tools/);
assert.doesNotMatch(clipsReady.description, /must|required/);
assert.notEqual(clipsReady.status, 'Optional');

const clipsEdited = getEditorTaskPresentation({ ...base, workflow: 'short', cutCount: 1 });
assert.equal(clipsEdited.title, 'Prepare your clips');
Expand All @@ -105,10 +108,16 @@ assert.equal(projectReady.title, 'Project ready');

assert.equal(getEditorTaskPresentation({ ...base, workflow: 'full-video', activePanel: 'ai' }).title, 'AI tools');
assert.equal(getEditorTaskPresentation({ ...base, workflow: 'short', activePanel: 'ai' }).title, 'Create Clips');
assert.equal(getEditorTaskPresentation({ ...base, workflow: 'short', activePanel: 'ai' }).status, 'Ready to find');
assert.equal(
getEditorTaskPresentation({ ...base, workflow: 'short', activePanel: 'ai' }).description,
'Find, review, prepare, and export moments from your recording.',
);
assert.equal(getEditorTaskPresentation({ ...base, workflow: 'full-video', activePanel: 'export' }).title, 'Export');
assert.equal(getEditorTaskPresentation({ ...base, workflow: 'full-video', activePanel: 'settings' }).title, 'Settings');
assert.equal(getEditorTaskPresentation({ ...base, workflow: 'full-video', wordCount: 0 }).title, 'Waiting for transcript');

const clipSuggestion = { startWordIndex: 0, endWordIndex: 4 };
assert.equal(getProjectWorkflow({ clipSuggestions: [clipSuggestion] }), 'short');
assert.equal(getProjectWorkflow({ clipDrafts: [{ ...clipSuggestion, status: 'draft' }] }), 'short');
assert.equal(getProjectWorkflow({ clipReviewDecisions: { 'clip-0-4': 'approved' } }), 'project');
3 changes: 2 additions & 1 deletion frontend/scripts/smoke-errors-accessibility.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ const openFileBody = app.match(/const handleOpenFile = async \(intent: WorkflowI
const browserChangeBody = app.match(/const handleBrowserFileChange = async \(e: React\.ChangeEvent<HTMLInputElement>\) => \{([\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/);
Expand Down
45 changes: 32 additions & 13 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { getCoreReadiness } from './utils/homeReadiness';
import {
getEditorTaskPresentation,
getPostTranscriptionPanel,
getProjectWorkflow,
type EditorPanel,
type EditorWorkflow,
} from './utils/editorTask';
Expand Down Expand Up @@ -90,6 +91,7 @@ export default function App() {

const [activePanel, setActivePanel] = useState<Panel>(null);
const [editorWorkflow, setEditorWorkflow] = useState<EditorWorkflow>('full-video');
const [workspaceRevision, setWorkspaceRevision] = useState(0);
const [showMoreMenu, setShowMoreMenu] = useState(false);
const [transcriptionEngine, setTranscriptionEngine] = useState<TranscriptionEngine>('auto');
const [transcriptionModel, setTranscriptionModel] = useState(AUTOMATIC_TRANSCRIPTION_MODEL);
Expand Down Expand Up @@ -214,6 +216,22 @@ export default function App() {
refreshRecentProjects();
};

const restoreProject = (data: ReturnType<typeof parseProjectFile>) => {
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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -352,7 +368,6 @@ export default function App() {
await transcribeVideo(path, intent);
}
} else {
applyWorkflowIntent(intent);
setBrowserWorkflowIntent(intent);
fileInputRef.current?.click();
}
Expand All @@ -362,16 +377,13 @@ export default function App() {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setEditorWorkflow(browserWorkflowIntent);
await uploadBrowserFile(file, browserWorkflowIntent);
};

const handleBrowserDrop = async (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const file = e.dataTransfer.files?.[0];
if (!file) return;
setEditorWorkflow('full-video');
applyWorkflowIntent('full-video');
await uploadBrowserFile(file, 'full-video');
};

Expand Down Expand Up @@ -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) {
Expand All @@ -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.
Expand Down Expand Up @@ -702,12 +717,13 @@ export default function App() {
/>
<ToolbarButton
icon={<Download className="w-4 h-4" />}
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"
/>
<div className="relative">
<button
Expand Down Expand Up @@ -846,7 +862,7 @@ export default function App() {
aria-label={sidePanelLabel}
className="w-80 border-l border-editor-border overflow-y-auto shrink-0"
>
{activePanel === 'ai' && <AIPanel mode={editorWorkflow === 'short' ? 'clips' : 'general'} />}
{activePanel === 'ai' && <AIPanel key={workspaceRevision} mode={editorWorkflow === 'short' ? 'clips' : 'general'} />}
{activePanel === 'export' && <ExportDialog />}
{activePanel === 'settings' && <SettingsPanel />}
</aside>
Expand Down Expand Up @@ -900,6 +916,7 @@ function ToolbarButton({
disabled,
controls,
expanded,
dataAction,
}: {
icon: React.ReactNode;
label: string;
Expand All @@ -908,12 +925,14 @@ function ToolbarButton({
disabled?: boolean;
controls?: string;
expanded?: boolean;
dataAction?: string;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
title={label}
data-export-action={dataAction}
aria-expanded={controls ? expanded ?? active ?? false : undefined}
aria-controls={controls}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
Expand Down
Loading