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
17 changes: 13 additions & 4 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const {
let mainWindow = null;
let pythonBackend = null;
let backendStartupError = '';
let backendStartupState = 'idle';

const isDev = !app.isPackaged;
const PROJECT_ROOT = path.join(__dirname, '..');
Expand Down Expand Up @@ -151,14 +152,20 @@ app.whenReady().then(async () => {
userDataPath: app.getPath('userData'),
projectRoot: PROJECT_ROOT,
});
const rendererSmoke = process.env.SCRIPTCUT_RENDERER_SMOKE === '1';
const creatorQualificationSmoke = process.env.SCRIPTCUT_CREATOR_QUALIFICATION_SMOKE === '1';
if (!rendererSmoke && !creatorQualificationSmoke) createWindow();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register the macOS activate handler before backend startup

Creating the window here before awaiting backend startup introduces a macOS lifecycle gap because the app.on('activate', ...) handler is still registered only after pythonBackend.start() completes. If a user closes the initial window while the backend is starting and then clicks the Dock icon during that interval, the activation is ignored; when startup later finishes, no code recreates the window, so the app remains windowless until another activation. Register the handler before starting the backend or check for an empty window list after startup.

Useful? React with 👍 / 👎.

backendStartupState = 'starting';
try {
await pythonBackend.start();
backendStartupState = 'ready';
} catch (error) {
backendStartupError = error instanceof Error ? error.message : String(error);
backendStartupState = 'error';
console.error('[backend] Startup failed:', backendStartupError);
}

if (process.env.SCRIPTCUT_RENDERER_SMOKE === '1') {
if (rendererSmoke) {
try {
if (!app.isPackaged) throw new Error('renderer transport smoke requires a packaged app');
const { runRendererTransportSmoke } = require('./renderer-transport-smoke');
Expand All @@ -182,7 +189,7 @@ app.whenReady().then(async () => {
return;
}

if (process.env.SCRIPTCUT_CREATOR_QUALIFICATION_SMOKE === '1') {
if (creatorQualificationSmoke) {
try {
if (!app.isPackaged) throw new Error('creator qualification smoke requires a packaged app');
const { runCreatorQualificationSmoke } = require('./creator-qualification-smoke');
Expand Down Expand Up @@ -211,7 +218,6 @@ app.whenReady().then(async () => {
return;
}

createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
Expand Down Expand Up @@ -284,7 +290,10 @@ ipcMain.handle('file:getUrl', (event, filePath) => {
assertTrustedSender(event);
return createApprovedBackendFileUrl(filePath);
});
ipcMain.handle('app:getStartupStatus', (event) => { assertTrustedSender(event); return { backendError: backendStartupError }; });
ipcMain.handle('app:getStartupStatus', (event) => {
assertTrustedSender(event);
return { backendError: backendStartupError, backendState: backendStartupState };
});
ipcMain.handle('app:getInfo', (event) => {
assertTrustedSender(event);
return { version: app.getVersion(), platform: process.platform, arch: process.arch, packaged: app.isPackaged, electron: process.versions.electron };
Expand Down
52 changes: 43 additions & 9 deletions frontend/scripts/smoke-settings-export-ux.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const readSource = (relativePath) => readFileSync(resolve(__dirname, relativePat

const settingsSource = readSource('../src/components/SettingsPanel.tsx');
const exportSource = readSource('../src/components/ExportDialog.tsx');
const exportControllerSource = readSource('../src/features/export/useFullVideoExportController.ts');
const appSource = readSource('../src/App.tsx');
const releaseSource = readSource('../src/utils/releaseInfo.ts');
const editorStoreSource = readSource('../src/store/editorStore.ts');
const helperSource = readSource('../src/utils/settingsUx.ts');
Expand Down Expand Up @@ -65,17 +67,49 @@ for (const technicalControl of [
]) {
assert.match(exportSource, new RegExp(technicalControl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
}
for (const contract of [
'getExportPreflight',
'startExport',
'/jobs/export',
'pollExportJob',
'/retry',
'/cancel',
'getFriendlyExportError',
]) {
for (const contract of ['getExportPreflight', 'startExport', 'getFriendlyExportError', 'applyExportCompletion']) {
assert.match(exportSource, new RegExp(contract.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
}
for (const contract of ['/jobs/export', 'pollExportJob', '/retry', '/cancel']) {
assert.match(exportControllerSource, new RegExp(contract.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
}
assert.match(appSource, /const fullVideoExportController = useFullVideoExportController\(\{ backendUrl, workspaceRevision \}\)/);
assert.match(appSource, /activePanel === 'export' && <ExportDialog controller=\{fullVideoExportController\} \/>/);
assert.ok(
appSource.indexOf('useFullVideoExportController({ backendUrl, workspaceRevision })') < appSource.indexOf("activePanel === 'export'"),
'full-video export controller must remain mounted above the conditional ExportDialog panel',
);
assert.match(exportControllerSource, /const activeRunRef = useRef<FullVideoExportRun \| null>\(null\)/);
assert.match(exportControllerSource, /Another full-video export is still running/);
assert.match(exportControllerSource, /Connection interrupted; checking export status[\s\S]*setExporting\(true, activeProgressRef\.current\)[\s\S]*continue/);
assert.match(exportControllerSource, /if \(!res\.ok\) \{[\s\S]*res\.status === 404[\s\S]*Export status unavailable; retrying[\s\S]*continue/);
assert.match(exportControllerSource, /Export status was unreadable; retrying[\s\S]*setExporting\(true, activeProgressRef\.current\)[\s\S]*continue/);
assert.match(exportControllerSource, /const mirroredIsExporting = useEditorStore\(\(state\) => state\.isExporting\)/);
assert.match(exportControllerSource, /const mirroredExportProgress = useEditorStore\(\(state\) => state\.exportProgress\)/);
assert.match(exportControllerSource, /if \(!mirroredIsExporting \|\| mirroredExportProgress !== activeProgressRef\.current\) \{\s*setExporting\(true, activeProgressRef\.current\);\s*\}/);
assert.match(exportControllerSource, /workspaceRevision: number/);
assert.match(exportControllerSource, /const currentWorkspaceRef = useRef\(\{ mediaPath: currentMediaPath, workspaceRevision \}\)/);
assert.match(exportControllerSource, /context\.workspaceRevision === currentWorkspaceRef\.current\.workspaceRevision/);
assert.match(exportControllerSource, /if \(!isCurrentWorkspace\(context\)\) return null;[\s\S]*const run = beginRun\(context\)/);
const cancelTransportBody = exportControllerSource.match(/const cancelExportJob = useCallback\(async \(\) => \{([\s\S]*?)\n\s*\}, \[/)?.[1] || '';
assert.match(cancelTransportBody, /job\.runId !== run\.id \|\| !isCurrentRun\(run\)/);
assert.doesNotMatch(cancelTransportBody, /setExporting\(false/);
const retryTransportBody = exportControllerSource.match(/const retryExportJob = useCallback\(async \(\) => \{([\s\S]*?)\n\s*\}, \[/)?.[1] || '';
assert.match(retryTransportBody, /\['failed', 'canceled'\]\.includes\(sourceJob\.status\)/);
assert.match(retryTransportBody, /!isCurrentWorkspace\(sourceJob\.context\)/);
assert.match(retryTransportBody, /const run = beginRun\(sourceJob\.context\)/);
assert.doesNotMatch(exportControllerSource, /getExportPreflight|localStorage|rememberExport|buildBackendFileUrl|setExportOptions/);
assert.doesNotMatch(exportSource, /\/jobs\/export|\/jobs\/\$\{[^}]+\}\/cancel|\/jobs\/\$\{[^}]+\}\/retry/);
assert.match(exportSource, /completion\.context\.preset/);
assert.match(exportSource, /completion\.context\.format/);
assert.match(exportSource, /!mountedRef\.current/);
assert.match(exportSource, /useEditorStore\.getState\(\)\.videoPath !== completion\.context\.mediaPath/);
assert.match(exportControllerSource, /setPendingCompletion\(\{ jobId, completion \}\)/);
assert.match(exportSource, /pendingCompletion\.completion\.context\.mediaPath !== videoPath/);
assert.match(exportSource, /pendingCompletion\.completion\.context\.workspaceRevision !== workspaceRevision/);
assert.match(exportSource, /workspaceRevision,\s*preset: exportOptions\.preset/);
assert.match(exportSource, /applyExportCompletion\(pendingCompletion\.completion\)/);
assert.match(exportSource, /acknowledgeCompletion\(pendingCompletion\.jobId\)/);
assert.match(editorStoreSource, /preset: 'source'/);
assert.match(editorStoreSource, /mode: 'fast'/);
assert.match(editorStoreSource, /aspectRatio: 'source'/);
Expand Down
87 changes: 70 additions & 17 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { useCallback, useEffect, useState, useRef } from 'react';
import { lazy, Suspense, useCallback, useEffect, useState, useRef } from 'react';
import { useEditorStore } from './store/editorStore';
import { useAIStore } from './store/aiStore';
import VideoPlayer from './components/VideoPlayer';
import TranscriptEditor from './components/TranscriptEditor';
import WaveformTimeline from './components/WaveformTimeline';
import AIPanel from './components/AIPanel';
import ExportDialog from './components/ExportDialog';
import SettingsPanel from './components/SettingsPanel';
import EditorTaskHeader from './components/EditorTaskHeader';
import HomeScreen, {
type SystemChecksResponse,
Expand All @@ -21,6 +18,7 @@ import {
type TranscriptionEngineStatus,
} from './utils/transcriptionModels';
import { useTranscriptionController } from './features/transcription/useTranscriptionController';
import { useFullVideoExportController } from './features/export/useFullVideoExportController';
import { saveProject, useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import {
getAutosaveCandidatePaths,
Expand Down Expand Up @@ -62,6 +60,9 @@ import { resolveBackendFileUrl } from './utils/backendFile';

const IS_ELECTRON = !!window.electronAPI;
const ONBOARDING_DISMISSED_KEY = 'scriptcut.onboarding.dismissed.v1';
const AIPanel = lazy(() => import('./components/AIPanel'));
const ExportDialog = lazy(() => import('./components/ExportDialog'));
const SettingsPanel = lazy(() => import('./components/SettingsPanel'));

type Panel = EditorPanel;

Expand Down Expand Up @@ -98,12 +99,13 @@ export default function App() {
const [systemChecks, setSystemChecks] = useState<SystemChecksResponse | null>(null);
const [systemChecksError, setSystemChecksError] = useState('');
const [backendStartupError, setBackendStartupError] = useState('');
const [backendReady, setBackendReady] = useState(!IS_ELECTRON);
const [creatorNotice, setCreatorNotice] = useState<CreatorNoticeData | null>(null);
const [autosaveRestoreRequest, setAutosaveRestoreRequest] = useState<{
data: ReturnType<typeof parseProjectFile>;
resolve: (restore: boolean) => void;
} | null>(null);
const [isCheckingSystem, setIsCheckingSystem] = useState(false);
const [isCheckingSystem, setIsCheckingSystem] = useState(IS_ELECTRON);
const [onboardingDismissed, setOnboardingDismissed] = useState(
() => window.localStorage.getItem(ONBOARDING_DISMISSED_KEY) === 'true',
);
Expand All @@ -130,22 +132,56 @@ export default function App() {
transcriptionModel,
onCompleted: handleTranscriptionCompleted,
});
const fullVideoExportController = useFullVideoExportController({ backendUrl, workspaceRevision });

useKeyboardShortcuts();
const autosave = useProjectAutosave();

useEffect(() => {
if (IS_ELECTRON) {
window.electronAPI!.getBackendUrl().then(setBackendUrl);
window.electronAPI!.getStartupStatus().then(({ backendError }) => {
if (!backendError) return;
setBackendStartupError(String(backendError));
if (!IS_ELECTRON) return;
let canceled = false;
let retryTimer: number | undefined;

window.electronAPI!.getBackendUrl().then((url) => {
if (!canceled) setBackendUrl(url);
});

const syncStartupStatus = async () => {
try {
const { backendError, backendState } = await window.electronAPI!.getStartupStatus();
if (canceled) return;
if (backendState === 'ready') {
setBackendStartupError('');
setBackendReady(true);
return;
}
if (backendState === 'error' || backendError) {
setBackendReady(false);
setBackendStartupError(String(backendError || 'Local editing service could not start.'));
setOnboardingDismissed(false);
return;
}
retryTimer = window.setTimeout(() => void syncStartupStatus(), 250);
} catch (error) {
if (canceled) return;
setBackendReady(false);
setBackendStartupError(error instanceof Error ? error.message : String(error));
setOnboardingDismissed(false);
});
}
}
};

void syncStartupStatus();
return () => {
canceled = true;
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
};
}, [setBackendUrl]);

useEffect(() => {
if (!backendReady) {
setTranscriptionEngineStatus(null);
return;
}
let canceled = false;
fetch(`${backendUrl}/transcription/engines`)
.then((res) => (res.ok ? res.json() : null))
Expand All @@ -159,9 +195,15 @@ export default function App() {
return () => {
canceled = true;
};
}, [backendUrl]);
}, [backendUrl, backendReady]);

const refreshSystemChecks = useCallback(async () => {
if (IS_ELECTRON && !backendReady) {
setSystemChecks(null);
setSystemChecksError('');
setIsCheckingSystem(!backendStartupError);
return;
}
setIsCheckingSystem(true);
setSystemChecksError('');
try {
Expand All @@ -174,7 +216,7 @@ export default function App() {
} finally {
setIsCheckingSystem(false);
}
}, [backendUrl]);
}, [backendStartupError, backendUrl, backendReady]);

useEffect(() => {
void refreshSystemChecks();
Expand Down Expand Up @@ -775,9 +817,11 @@ export default function App() {
aria-label={sidePanelLabel}
className="w-80 border-l border-editor-border overflow-y-auto shrink-0"
>
{activePanel === 'ai' && <AIPanel key={workspaceRevision} mode={editorWorkflow === 'short' ? 'clips' : 'general'} />}
{activePanel === 'export' && <ExportDialog />}
{activePanel === 'settings' && <SettingsPanel />}
<Suspense fallback={<PanelLoading label={sidePanelLabel} />}>
{activePanel === 'ai' && <AIPanel key={workspaceRevision} mode={editorWorkflow === 'short' ? 'clips' : 'general'} />}
{activePanel === 'export' && <ExportDialog controller={fullVideoExportController} />}
{activePanel === 'settings' && <SettingsPanel />}
</Suspense>
</aside>
)}
</div>
Expand All @@ -787,6 +831,15 @@ export default function App() {
);
}

function PanelLoading({ label }: { label: string }) {
return (
<div className="flex min-h-32 items-center justify-center gap-2 px-4 text-xs text-editor-text-muted" role="status">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading {label.toLowerCase()}…
</div>
);
}

function AutosaveStatus({ autosave }: { autosave: ReturnType<typeof useProjectAutosave> }) {
if (autosave.status === 'idle') return null;
if (autosave.status === 'unavailable') {
Expand Down
Loading
Loading