mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
fix(projects): streamline import and save flow
This commit is contained in:
Vendored
+10
-1
@@ -649,7 +649,16 @@ interface Window {
|
||||
error?: string;
|
||||
canceled?: boolean;
|
||||
}>;
|
||||
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
|
||||
openVideoFilePicker: (options?: { includeProjects?: boolean }) => Promise<{
|
||||
success: boolean;
|
||||
kind?: "media" | "project";
|
||||
path?: string;
|
||||
project?: unknown;
|
||||
extension?: string;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
openAudioFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
|
||||
openWhisperExecutablePicker: () => Promise<{
|
||||
success: boolean;
|
||||
|
||||
+229
-181
@@ -1,207 +1,255 @@
|
||||
import path from "node:path";
|
||||
import { dialog, ipcMain } from "electron";
|
||||
import { setCurrentProjectPath } from "../state";
|
||||
import { generateAutoCaptionsFromVideo } from "../captions/generate";
|
||||
import {
|
||||
getWhisperSmallModelStatus,
|
||||
downloadWhisperSmallModel,
|
||||
deleteWhisperSmallModel,
|
||||
downloadWhisperSmallModel,
|
||||
getWhisperSmallModelStatus,
|
||||
sendWhisperModelDownloadProgress,
|
||||
} from "../captions/whisper";
|
||||
import { generateAutoCaptionsFromVideo } from "../captions/generate";
|
||||
import { LEGACY_PROJECT_FILE_EXTENSIONS, PROJECT_FILE_EXTENSION } from "../constants";
|
||||
import { hasProjectFileExtension, loadProjectFromPath } from "../project/manager";
|
||||
import { setCurrentProjectPath } from "../state";
|
||||
import { approveUserPath, getRecordingsDir } from "../utils";
|
||||
|
||||
const VIDEO_FILE_EXTENSIONS = ["webm", "mp4", "mov", "avi", "mkv"];
|
||||
const PROJECT_FILE_EXTENSIONS = [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS];
|
||||
|
||||
type OpenVideoFilePickerOptions = {
|
||||
includeProjects?: boolean;
|
||||
};
|
||||
|
||||
export function registerCaptionHandlers() {
|
||||
ipcMain.handle('open-video-file-picker', async () => {
|
||||
try {
|
||||
const recordingsDir = await getRecordingsDir()
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Select Video File',
|
||||
defaultPath: recordingsDir,
|
||||
filters: [
|
||||
{ name: 'Video Files', extensions: ['webm', 'mp4', 'mov', 'avi', 'mkv'] },
|
||||
{ name: 'All Files', extensions: ['*'] }
|
||||
],
|
||||
properties: ['openFile']
|
||||
});
|
||||
ipcMain.handle("open-video-file-picker", async (_, options?: OpenVideoFilePickerOptions) => {
|
||||
try {
|
||||
const includeProjects = Boolean(options?.includeProjects);
|
||||
const recordingsDir = await getRecordingsDir();
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: includeProjects ? "Import Media or Recordly Project" : "Select Video File",
|
||||
defaultPath: recordingsDir,
|
||||
filters: [
|
||||
...(includeProjects
|
||||
? [
|
||||
{
|
||||
name: "Media or Recordly Projects",
|
||||
extensions: [
|
||||
...VIDEO_FILE_EXTENSIONS,
|
||||
...PROJECT_FILE_EXTENSIONS,
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ name: "Video Files", extensions: VIDEO_FILE_EXTENSIONS },
|
||||
...(includeProjects
|
||||
? [{ name: "Recordly Projects", extensions: PROJECT_FILE_EXTENSIONS }]
|
||||
: []),
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
properties: ["openFile"],
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
|
||||
approveUserPath(result.filePaths[0])
|
||||
setCurrentProjectPath(null)
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePaths[0]
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to open file picker:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to open file picker',
|
||||
error: String(error)
|
||||
};
|
||||
}
|
||||
});
|
||||
const selectedPath = result.filePaths[0];
|
||||
|
||||
ipcMain.handle('open-audio-file-picker', async () => {
|
||||
try {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Select Audio File',
|
||||
filters: [
|
||||
{ name: 'Audio Files', extensions: ['mp3', 'wav', 'aac', 'm4a', 'flac', 'ogg'] },
|
||||
{ name: 'All Files', extensions: ['*'] }
|
||||
],
|
||||
properties: ['openFile']
|
||||
});
|
||||
if (includeProjects && hasProjectFileExtension(selectedPath)) {
|
||||
const projectResult = await loadProjectFromPath(selectedPath);
|
||||
return projectResult.success
|
||||
? { ...projectResult, kind: "project" }
|
||||
: projectResult;
|
||||
}
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
approveUserPath(selectedPath);
|
||||
setCurrentProjectPath(null);
|
||||
return {
|
||||
success: true,
|
||||
kind: "media",
|
||||
path: selectedPath,
|
||||
extension: path.extname(selectedPath).replace(/^\./, "").toLowerCase(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to open file picker:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to open file picker",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
approveUserPath(result.filePaths[0])
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePaths[0]
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to open audio file picker:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to open audio file picker',
|
||||
error: String(error)
|
||||
};
|
||||
}
|
||||
});
|
||||
ipcMain.handle("open-audio-file-picker", async () => {
|
||||
try {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: "Select Audio File",
|
||||
filters: [
|
||||
{
|
||||
name: "Audio Files",
|
||||
extensions: ["mp3", "wav", "aac", "m4a", "flac", "ogg"],
|
||||
},
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
properties: ["openFile"],
|
||||
});
|
||||
|
||||
ipcMain.handle('open-whisper-executable-picker', async () => {
|
||||
try {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Select Whisper Executable',
|
||||
filters: [
|
||||
{ name: 'Executables', extensions: process.platform === 'win32' ? ['exe', 'cmd', 'bat'] : ['*'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
properties: ['openFile'],
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true }
|
||||
}
|
||||
approveUserPath(result.filePaths[0]);
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePaths[0],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to open audio file picker:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to open audio file picker",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
approveUserPath(result.filePaths[0])
|
||||
return { success: true, path: result.filePaths[0] }
|
||||
} catch (error) {
|
||||
console.error('Failed to open Whisper executable picker:', error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
ipcMain.handle("open-whisper-executable-picker", async () => {
|
||||
try {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: "Select Whisper Executable",
|
||||
filters: [
|
||||
{
|
||||
name: "Executables",
|
||||
extensions: process.platform === "win32" ? ["exe", "cmd", "bat"] : ["*"],
|
||||
},
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
properties: ["openFile"],
|
||||
});
|
||||
|
||||
ipcMain.handle('open-whisper-model-picker', async () => {
|
||||
try {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Select Whisper Model',
|
||||
filters: [
|
||||
{ name: 'Whisper Models', extensions: ['bin'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
properties: ['openFile'],
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true }
|
||||
}
|
||||
approveUserPath(result.filePaths[0]);
|
||||
return { success: true, path: result.filePaths[0] };
|
||||
} catch (error) {
|
||||
console.error("Failed to open Whisper executable picker:", error);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
approveUserPath(result.filePaths[0])
|
||||
return { success: true, path: result.filePaths[0] }
|
||||
} catch (error) {
|
||||
console.error('Failed to open Whisper model picker:', error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
ipcMain.handle("open-whisper-model-picker", async () => {
|
||||
try {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: "Select Whisper Model",
|
||||
filters: [
|
||||
{ name: "Whisper Models", extensions: ["bin"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
properties: ["openFile"],
|
||||
});
|
||||
|
||||
ipcMain.handle('get-whisper-small-model-status', async () => {
|
||||
try {
|
||||
return await getWhisperSmallModelStatus()
|
||||
} catch (error) {
|
||||
return { success: false, exists: false, path: null, error: String(error) }
|
||||
}
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
|
||||
ipcMain.handle('download-whisper-small-model', async (event) => {
|
||||
try {
|
||||
const existing = await getWhisperSmallModelStatus()
|
||||
if (existing.exists) {
|
||||
sendWhisperModelDownloadProgress(event.sender, {
|
||||
status: 'downloaded',
|
||||
progress: 100,
|
||||
path: existing.path,
|
||||
})
|
||||
return { success: true, path: existing.path, alreadyDownloaded: true }
|
||||
}
|
||||
approveUserPath(result.filePaths[0]);
|
||||
return { success: true, path: result.filePaths[0] };
|
||||
} catch (error) {
|
||||
console.error("Failed to open Whisper model picker:", error);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
const modelPath = await downloadWhisperSmallModel(event.sender)
|
||||
return { success: true, path: modelPath }
|
||||
} catch (error) {
|
||||
console.error('Failed to download Whisper small model:', error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
ipcMain.handle("get-whisper-small-model-status", async () => {
|
||||
try {
|
||||
return await getWhisperSmallModelStatus();
|
||||
} catch (error) {
|
||||
return { success: false, exists: false, path: null, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('delete-whisper-small-model', async (event) => {
|
||||
try {
|
||||
await deleteWhisperSmallModel()
|
||||
sendWhisperModelDownloadProgress(event.sender, {
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
path: null,
|
||||
})
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Failed to delete Whisper small model:', error)
|
||||
// Verify whether the file was actually removed despite the error
|
||||
const status = await getWhisperSmallModelStatus()
|
||||
if (!status.exists) {
|
||||
// File is gone — treat as success
|
||||
sendWhisperModelDownloadProgress(event.sender, {
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
path: null,
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
sendWhisperModelDownloadProgress(event.sender, {
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
path: null,
|
||||
error: String(error),
|
||||
})
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
ipcMain.handle("download-whisper-small-model", async (event) => {
|
||||
try {
|
||||
const existing = await getWhisperSmallModelStatus();
|
||||
if (existing.exists) {
|
||||
sendWhisperModelDownloadProgress(event.sender, {
|
||||
status: "downloaded",
|
||||
progress: 100,
|
||||
path: existing.path,
|
||||
});
|
||||
return { success: true, path: existing.path, alreadyDownloaded: true };
|
||||
}
|
||||
|
||||
ipcMain.handle('generate-auto-captions', async (_, options: {
|
||||
videoPath: string
|
||||
whisperExecutablePath: string
|
||||
whisperModelPath: string
|
||||
language?: string
|
||||
}) => {
|
||||
try {
|
||||
const result = await generateAutoCaptionsFromVideo(options)
|
||||
return {
|
||||
success: true,
|
||||
cues: result.cues,
|
||||
message: result.audioSourceLabel === 'recording'
|
||||
? `Generated ${result.cues.length} caption cues.`
|
||||
: `Generated ${result.cues.length} caption cues from the ${result.audioSourceLabel}.`,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate auto captions:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: String(error),
|
||||
message: 'Failed to generate auto captions',
|
||||
}
|
||||
}
|
||||
})
|
||||
const modelPath = await downloadWhisperSmallModel(event.sender);
|
||||
return { success: true, path: modelPath };
|
||||
} catch (error) {
|
||||
console.error("Failed to download Whisper small model:", error);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("delete-whisper-small-model", async (event) => {
|
||||
try {
|
||||
await deleteWhisperSmallModel();
|
||||
sendWhisperModelDownloadProgress(event.sender, {
|
||||
status: "idle",
|
||||
progress: 0,
|
||||
path: null,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to delete Whisper small model:", error);
|
||||
// Verify whether the file was actually removed despite the error
|
||||
const status = await getWhisperSmallModelStatus();
|
||||
if (!status.exists) {
|
||||
// File is gone — treat as success
|
||||
sendWhisperModelDownloadProgress(event.sender, {
|
||||
status: "idle",
|
||||
progress: 0,
|
||||
path: null,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
sendWhisperModelDownloadProgress(event.sender, {
|
||||
status: "error",
|
||||
progress: 0,
|
||||
path: null,
|
||||
error: String(error),
|
||||
});
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"generate-auto-captions",
|
||||
async (
|
||||
_,
|
||||
options: {
|
||||
videoPath: string;
|
||||
whisperExecutablePath: string;
|
||||
whisperModelPath: string;
|
||||
language?: string;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
const result = await generateAutoCaptionsFromVideo(options);
|
||||
return {
|
||||
success: true,
|
||||
cues: result.cues,
|
||||
message:
|
||||
result.audioSourceLabel === "recording"
|
||||
? `Generated ${result.cues.length} caption cues.`
|
||||
: `Generated ${result.cues.length} caption cues from the ${result.audioSourceLabel}.`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to generate auto captions:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: String(error),
|
||||
message: "Failed to generate auto captions",
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -671,8 +671,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
captionSidecar,
|
||||
);
|
||||
},
|
||||
openVideoFilePicker: () => {
|
||||
return ipcRenderer.invoke("open-video-file-picker");
|
||||
openVideoFilePicker: (options?: { includeProjects?: boolean }) => {
|
||||
return ipcRenderer.invoke("open-video-file-picker", options);
|
||||
},
|
||||
openAudioFilePicker: () => {
|
||||
return ipcRenderer.invoke("open-audio-file-picker");
|
||||
|
||||
@@ -19,8 +19,12 @@ export function useLaunchWindowActions() {
|
||||
}, []);
|
||||
|
||||
const openVideoFile = useCallback(async () => {
|
||||
const result = await window.electronAPI.openVideoFilePicker();
|
||||
const result = await window.electronAPI.openVideoFilePicker({ includeProjects: true });
|
||||
if (result.canceled) return;
|
||||
if (result.success && result.kind === "project") {
|
||||
await window.electronAPI.switchToEditor();
|
||||
return;
|
||||
}
|
||||
if (result.success && result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
await window.electronAPI.switchToEditor();
|
||||
|
||||
@@ -15,6 +15,7 @@ type ProjectBrowserDialogProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
entries: ProjectLibraryEntry[];
|
||||
onOpenProject: (projectPath: string) => void;
|
||||
onImportFile?: () => void;
|
||||
anchorRef?: React.RefObject<HTMLElement | null>;
|
||||
preferredDirection?: "up" | "down" | "auto";
|
||||
onPanelHeightChange?: (height: number) => void;
|
||||
@@ -25,6 +26,7 @@ export default function ProjectBrowserDialog({
|
||||
onOpenChange,
|
||||
entries,
|
||||
onOpenProject,
|
||||
onImportFile,
|
||||
anchorRef,
|
||||
preferredDirection = "auto",
|
||||
onPanelHeightChange,
|
||||
@@ -172,10 +174,21 @@ export default function ProjectBrowserDialog({
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-label="Projects"
|
||||
className="pointer-events-auto mb-1.5 w-[300px] max-h-[400px] overflow-hidden rounded-[14px] border border-foreground/[0.07] bg-editor-panel/[0.96] text-foreground shadow-[0_12px_32px_rgba(0,0,0,0.22),0_2px_10px_rgba(0,0,0,0.1)] animate-in fade-in-0 duration-150"
|
||||
className="pointer-events-auto mb-1.5 w-[300px] max-h-[400px] overflow-hidden rounded-[14px] border border-foreground/[0.07] bg-editor-panel/[0.96] text-foreground shadow-[0_12px_32px_rgba(0,0,0,0.22),0_2px_10px_rgba(0,0,0,0.1)] animate-in fade-in-0 duration-150"
|
||||
>
|
||||
<div className="border-b border-foreground/10 px-3 py-2.5">
|
||||
<div className="text-sm font-medium tracking-tight text-foreground">Projects</div>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-foreground/10 px-3 py-2.5">
|
||||
<div className="text-sm font-medium tracking-tight text-foreground">
|
||||
Projects
|
||||
</div>
|
||||
{onImportFile ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImportFile}
|
||||
className="rounded-md px-2 py-1 text-xs font-medium text-foreground/70 transition hover:bg-foreground/10 hover:text-foreground"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="max-h-[360px] overflow-y-auto px-2.5 py-2.5">
|
||||
{visibleEntries.length > 0 ? (
|
||||
@@ -242,8 +255,19 @@ export default function ProjectBrowserDialog({
|
||||
style={{ top: `${position.top}px`, left: `${position.left}px` }}
|
||||
className="pointer-events-auto fixed w-[min(280px,calc(100vw-24px))] overflow-hidden rounded-2xl border border-foreground/10 bg-editor-surface text-foreground shadow-2xl animate-in fade-in-0 duration-150"
|
||||
>
|
||||
<div className="border-b border-foreground/10 px-3 py-2.5">
|
||||
<div className="text-sm font-medium tracking-tight text-foreground">Projects</div>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-foreground/10 px-3 py-2.5">
|
||||
<div className="text-sm font-medium tracking-tight text-foreground">
|
||||
Projects
|
||||
</div>
|
||||
{onImportFile ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImportFile}
|
||||
className="rounded-md px-2 py-1 text-xs font-medium text-foreground/70 transition hover:bg-foreground/10 hover:text-foreground"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="overflow-y-auto px-2.5 py-2.5"
|
||||
|
||||
@@ -298,6 +298,10 @@ type SaveProjectOptions = {
|
||||
captureThumbnail?: boolean;
|
||||
};
|
||||
|
||||
type PendingProjectSaveDialog = {
|
||||
resolve: (saved: boolean) => void;
|
||||
};
|
||||
|
||||
async function writeSmokeExportReport(
|
||||
outputPath: string | null,
|
||||
report: Record<string, unknown>,
|
||||
@@ -382,6 +386,9 @@ export default function VideoEditor() {
|
||||
const [isEditingProjectName, setIsEditingProjectName] = useState(false);
|
||||
const [projectNameDraft, setProjectNameDraft] = useState("");
|
||||
const [isSavingProjectName, setIsSavingProjectName] = useState(false);
|
||||
const [projectSaveDialogOpen, setProjectSaveDialogOpen] = useState(false);
|
||||
const [projectSaveDialogDraft, setProjectSaveDialogDraft] = useState("");
|
||||
const [isSavingProjectDialog, setIsSavingProjectDialog] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -648,6 +655,7 @@ export default function VideoEditor() {
|
||||
const projectBrowserTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const projectBrowserFallbackTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const projectNameInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const projectSaveDialogInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const nextZoomIdRef = useRef(1);
|
||||
const nextClipIdRef = useRef(1);
|
||||
const nextAudioIdRef = useRef(1);
|
||||
@@ -668,6 +676,7 @@ export default function VideoEditor() {
|
||||
const mp4SupportRequestRef = useRef(0);
|
||||
const smokeExportStartedRef = useRef(false);
|
||||
const projectAutosaveTimeoutRef = useRef<number | null>(null);
|
||||
const pendingProjectSaveDialogRef = useRef<PendingProjectSaveDialog | null>(null);
|
||||
const projectSaveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const smokeExportReadyStateRef = useRef<Record<string, unknown>>({});
|
||||
const [historyVersion, setHistoryVersion] = useState(0);
|
||||
@@ -1734,6 +1743,21 @@ export default function VideoEditor() {
|
||||
};
|
||||
}, [isEditingProjectName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectSaveDialogOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
projectSaveDialogInputRef.current?.focus();
|
||||
projectSaveDialogInputRef.current?.select();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [projectSaveDialogOpen]);
|
||||
|
||||
const currentPersistedEditorState = useMemo(
|
||||
() =>
|
||||
buildPersistedEditorState({
|
||||
@@ -2127,6 +2151,25 @@ export default function VideoEditor() {
|
||||
);
|
||||
}, [currentPersistedEditorState, currentSourcePath, lastSavedSnapshot?.projectId]);
|
||||
|
||||
const resolveProjectSaveDialog = useCallback((saved: boolean) => {
|
||||
const pendingDialog = pendingProjectSaveDialogRef.current;
|
||||
pendingProjectSaveDialogRef.current = null;
|
||||
setProjectSaveDialogOpen(false);
|
||||
setIsSavingProjectDialog(false);
|
||||
pendingDialog?.resolve(saved);
|
||||
}, []);
|
||||
|
||||
const openProjectSaveDialog = useCallback((initialName: string) => {
|
||||
pendingProjectSaveDialogRef.current?.resolve(false);
|
||||
setProjectSaveDialogDraft(initialName);
|
||||
setProjectSaveDialogOpen(true);
|
||||
setIsSavingProjectDialog(false);
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
pendingProjectSaveDialogRef.current = { resolve };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const syncRecordingSessionWebcam = useCallback(
|
||||
async (webcamPath: string | null, timeOffsetMs?: number) => {
|
||||
if (!currentSourcePath || !window.electronAPI.setCurrentRecordingSession) {
|
||||
@@ -2831,6 +2874,14 @@ export default function VideoEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
if (forceSaveAs || !targetProjectPath) {
|
||||
if (options?.silent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return openProjectSaveDialog(projectDisplayName || fileNameBase);
|
||||
}
|
||||
|
||||
const thumbnailDataUrl = shouldCaptureThumbnail
|
||||
? await captureProjectThumbnail()
|
||||
: undefined;
|
||||
@@ -2891,6 +2942,8 @@ export default function VideoEditor() {
|
||||
currentProjectSnapshot,
|
||||
currentPersistedEditorState,
|
||||
lastSavedSnapshot?.projectId,
|
||||
openProjectSaveDialog,
|
||||
projectDisplayName,
|
||||
queueProjectSave,
|
||||
refreshProjectLibrary,
|
||||
remountPreview,
|
||||
@@ -3013,6 +3066,38 @@ export default function VideoEditor() {
|
||||
],
|
||||
);
|
||||
|
||||
const handleProjectSaveDialogSubmit = useCallback(
|
||||
async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
const trimmedProjectName = projectSaveDialogDraft.trim();
|
||||
|
||||
if (!trimmedProjectName) {
|
||||
toast.error("Project name is required");
|
||||
projectSaveDialogInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingProjectDialog(true);
|
||||
let saved = false;
|
||||
try {
|
||||
saved = await saveProjectWithName(trimmedProjectName);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setIsSavingProjectDialog(false);
|
||||
}
|
||||
|
||||
if (saved) {
|
||||
resolveProjectSaveDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
projectSaveDialogInputRef.current?.focus();
|
||||
projectSaveDialogInputRef.current?.select();
|
||||
},
|
||||
[projectSaveDialogDraft, resolveProjectSaveDialog, saveProjectWithName],
|
||||
);
|
||||
|
||||
/**
|
||||
* Resets the inline project-name editor back to the current saved display name.
|
||||
*/
|
||||
@@ -3080,6 +3165,74 @@ export default function VideoEditor() {
|
||||
[applyLoadedProject, refreshProjectLibrary],
|
||||
);
|
||||
|
||||
const handleImportMediaOrProject = useCallback(async () => {
|
||||
const result = await window.electronAPI.openVideoFilePicker({ includeProjects: true });
|
||||
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.message || "Failed to import file");
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.kind === "project" || result.project) {
|
||||
const restored = await applyLoadedProject(result.project, result.path ?? null);
|
||||
if (!restored) {
|
||||
toast.error("Invalid project file format");
|
||||
return;
|
||||
}
|
||||
|
||||
setProjectBrowserOpen(false);
|
||||
await refreshProjectLibrary();
|
||||
toast.success(result.path ? `Project loaded from ${result.path}` : "Project loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.path) {
|
||||
toast.error("No media file selected");
|
||||
return;
|
||||
}
|
||||
|
||||
const sourcePath = fromFileUrl(result.path);
|
||||
const sourceVideoUrl = await resolveVideoUrl(sourcePath);
|
||||
try {
|
||||
videoPlaybackRef.current?.pause();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setVideoSourcePath(sourcePath);
|
||||
setVideoPath(sourceVideoUrl);
|
||||
setCurrentProjectPath(null);
|
||||
setLastSavedSnapshot(null);
|
||||
resetSourceScopedEditorState();
|
||||
pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms
|
||||
? sourceVideoUrl
|
||||
: null;
|
||||
setWebcam((prev) => ({
|
||||
...prev,
|
||||
enabled: false,
|
||||
sourcePath: null,
|
||||
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
|
||||
}));
|
||||
applySessionPresentation(null);
|
||||
await window.electronAPI.setCurrentVideoPath(sourcePath, { preserveProjectPath: false });
|
||||
setProjectBrowserOpen(false);
|
||||
await refreshProjectLibrary();
|
||||
toast.success("Media imported");
|
||||
}, [
|
||||
applyLoadedProject,
|
||||
applySessionPresentation,
|
||||
autoApplyFreshRecordingAutoZooms,
|
||||
refreshProjectLibrary,
|
||||
resetSourceScopedEditorState,
|
||||
]);
|
||||
|
||||
const handleOpenProjectBrowser = useCallback(async () => {
|
||||
if (projectBrowserOpen) {
|
||||
setProjectBrowserOpen(false);
|
||||
@@ -3398,7 +3551,9 @@ export default function VideoEditor() {
|
||||
const handlePreviewSkipBack = useCallback(() => {
|
||||
const currentMs = timelinePlayheadTime * 1000;
|
||||
const keyframes = timelineRef.current?.keyframes ?? [];
|
||||
const previous = [...keyframes].reverse().find((keyframe) => keyframe.time < currentMs - 50);
|
||||
const previous = [...keyframes]
|
||||
.reverse()
|
||||
.find((keyframe) => keyframe.time < currentMs - 50);
|
||||
handleSeek(previous ? previous.time / 1000 : Math.max(0, timelinePlayheadTime - 5));
|
||||
}, [handleSeek, timelinePlayheadTime]);
|
||||
|
||||
@@ -3406,9 +3561,7 @@ export default function VideoEditor() {
|
||||
const currentMs = timelinePlayheadTime * 1000;
|
||||
const keyframes = timelineRef.current?.keyframes ?? [];
|
||||
const next = keyframes.find((keyframe) => keyframe.time > currentMs + 50);
|
||||
handleSeek(
|
||||
next ? next.time / 1000 : Math.min(timelineDuration, timelinePlayheadTime + 5),
|
||||
);
|
||||
handleSeek(next ? next.time / 1000 : Math.min(timelineDuration, timelinePlayheadTime + 5));
|
||||
}, [handleSeek, timelineDuration, timelinePlayheadTime]);
|
||||
|
||||
const handleSelectZoom = useCallback((id: string | null) => {
|
||||
@@ -5215,21 +5368,84 @@ export default function VideoEditor() {
|
||||
volume={
|
||||
audio.shouldMutePreviewVideo || audio.isCurrentClipMuted
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(1, previewVolume * audio.embeddedSourcePreviewGain),
|
||||
)
|
||||
: Math.max(0, Math.min(1, previewVolume * audio.embeddedSourcePreviewGain))
|
||||
}
|
||||
suspendRendering={suspendRendering}
|
||||
/>
|
||||
);
|
||||
|
||||
const projectSaveDialog = (
|
||||
<Dialog
|
||||
open={projectSaveDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setProjectSaveDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSavingProjectDialog) {
|
||||
resolveProjectSaveDialog(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm border-foreground/10 bg-editor-dialog text-foreground">
|
||||
<form onSubmit={(event) => void handleProjectSaveDialogSubmit(event)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("editor.project.saveTitle", "Save Project")}</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t(
|
||||
"editor.project.saveDescription",
|
||||
"Name this project. It will be saved in your Recordly Projects folder.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
{t("editor.project.saveNameLabel", "Project name")}
|
||||
</label>
|
||||
<div className="flex items-center overflow-hidden rounded-md border border-foreground/10 bg-editor-panel">
|
||||
<Input
|
||||
ref={projectSaveDialogInputRef}
|
||||
value={projectSaveDialogDraft}
|
||||
onChange={(event) => setProjectSaveDialogDraft(event.target.value)}
|
||||
disabled={isSavingProjectDialog}
|
||||
className="h-10 flex-1 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
aria-label={t("editor.project.saveNameLabel", "Project name")}
|
||||
/>
|
||||
<span className="shrink-0 px-3 text-xs font-medium text-muted-foreground/70">
|
||||
.recordly
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => resolveProjectSaveDialog(false)}
|
||||
disabled={isSavingProjectDialog}
|
||||
>
|
||||
{t("common.actions.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSavingProjectDialog}>
|
||||
{isSavingProjectDialog
|
||||
? t("editor.project.saving", "Saving...")
|
||||
: t("common.actions.save", "Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
const projectBrowser = (
|
||||
<ProjectBrowserDialog
|
||||
open={projectBrowserOpen}
|
||||
onOpenChange={setProjectBrowserOpen}
|
||||
entries={projectLibraryEntries}
|
||||
anchorRef={error ? projectBrowserFallbackTriggerRef : projectBrowserTriggerRef}
|
||||
onImportFile={() => {
|
||||
void handleImportMediaOrProject();
|
||||
}}
|
||||
onOpenProject={(projectPath) => {
|
||||
void handleOpenProjectFromLibrary(projectPath);
|
||||
}}
|
||||
@@ -5269,6 +5485,7 @@ export default function VideoEditor() {
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-foreground">Loading video...</div>
|
||||
{projectBrowser}
|
||||
{projectSaveDialog}
|
||||
{nativeCaptureUnavailableDialog}
|
||||
<Toaster className="pointer-events-auto" />
|
||||
</div>
|
||||
@@ -5289,6 +5506,7 @@ export default function VideoEditor() {
|
||||
</button>
|
||||
</div>
|
||||
{projectBrowser}
|
||||
{projectSaveDialog}
|
||||
{nativeCaptureUnavailableDialog}
|
||||
<Toaster className="pointer-events-auto" />
|
||||
</div>
|
||||
@@ -5736,7 +5954,9 @@ export default function VideoEditor() {
|
||||
onGifLoopChange={setGifLoop}
|
||||
gifSizePreset={gifSizePreset}
|
||||
onGifSizePresetChange={setGifSizePreset}
|
||||
showCaptionSidecarOption={hasCaptionsForSidecar && exportFormat === "mp4"}
|
||||
showCaptionSidecarOption={
|
||||
hasCaptionsForSidecar && exportFormat === "mp4"
|
||||
}
|
||||
includeCaptionSidecar={includeCaptionSidecar}
|
||||
onIncludeCaptionSidecarChange={setIncludeCaptionSidecar}
|
||||
mp4OutputDimensions={mp4OutputDimensions}
|
||||
@@ -6415,6 +6635,7 @@ export default function VideoEditor() {
|
||||
) : null}
|
||||
|
||||
{projectBrowser}
|
||||
{projectSaveDialog}
|
||||
{nativeCaptureUnavailableDialog}
|
||||
|
||||
<Toaster className="pointer-events-auto" />
|
||||
|
||||
@@ -42,12 +42,43 @@ function areDeepEqual(left: unknown, right: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function omitTransientWebcamMediaFields(project: EditorProjectData | null) {
|
||||
if (!project?.editor || typeof project.editor !== "object") {
|
||||
return project;
|
||||
}
|
||||
|
||||
const editor = project.editor as Record<string, unknown>;
|
||||
const webcam = editor.webcam;
|
||||
if (!isComparableObject(webcam)) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const {
|
||||
enabled: _enabled,
|
||||
sourcePath: _sourcePath,
|
||||
timeOffsetMs: _timeOffsetMs,
|
||||
...persistentWebcamFields
|
||||
} = webcam;
|
||||
|
||||
return {
|
||||
...project,
|
||||
editor: {
|
||||
...editor,
|
||||
webcam: persistentWebcamFields,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function hasUnsavedProjectChanges(
|
||||
currentProjectSnapshot: EditorProjectData | null,
|
||||
lastSavedSnapshot: EditorProjectData | null,
|
||||
): boolean {
|
||||
const comparableCurrentSnapshot = omitTransientWebcamMediaFields(currentProjectSnapshot);
|
||||
const comparableLastSavedSnapshot = omitTransientWebcamMediaFields(lastSavedSnapshot);
|
||||
|
||||
return Boolean(
|
||||
currentProjectSnapshot &&
|
||||
(!lastSavedSnapshot || !areDeepEqual(currentProjectSnapshot, lastSavedSnapshot)),
|
||||
comparableCurrentSnapshot &&
|
||||
(!comparableLastSavedSnapshot ||
|
||||
!areDeepEqual(comparableCurrentSnapshot, comparableLastSavedSnapshot)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ZOOM_OUT_EARLY_START_MS,
|
||||
} from "./constants";
|
||||
import { clampFocusToScale } from "./focusUtils";
|
||||
import { clamp01, cubicBezier, easeOutZoom } from "./mathUtils";
|
||||
import { clamp01, easeOutZoom } from "./mathUtils";
|
||||
|
||||
const CHAINED_ZOOM_PAN_GAP_MS = 1350;
|
||||
const CONNECTED_ZOOM_PAN_DURATION_MS = 1000;
|
||||
@@ -34,14 +34,6 @@ type ConnectedPanTransition = {
|
||||
endScale: number;
|
||||
};
|
||||
|
||||
function lerp(start: number, end: number, amount: number) {
|
||||
return start + (end - start) * amount;
|
||||
}
|
||||
|
||||
function easeConnectedPan(value: number) {
|
||||
return cubicBezier(0.1, 0.0, 0.2, 1.0, value);
|
||||
}
|
||||
|
||||
export function computeRegionStrength(
|
||||
region: ZoomRegion,
|
||||
timeMs: number,
|
||||
@@ -79,13 +71,6 @@ export function computeRegionStrength(
|
||||
return 1 - easeOutZoom(progress);
|
||||
}
|
||||
|
||||
function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus {
|
||||
return {
|
||||
cx: lerp(start.cx, end.cx, amount),
|
||||
cy: lerp(start.cy, end.cy, amount),
|
||||
};
|
||||
}
|
||||
|
||||
function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus {
|
||||
return clampFocusToScale(region.focus, zoomScale);
|
||||
}
|
||||
@@ -199,44 +184,6 @@ function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionP
|
||||
return null;
|
||||
}
|
||||
|
||||
function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], timeMs: number) {
|
||||
for (const pair of connectedPairs) {
|
||||
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
|
||||
|
||||
if (timeMs < transitionStart || timeMs > transitionEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const transitionProgress = easeConnectedPan(
|
||||
clamp01((timeMs - transitionStart) / Math.max(1, transitionEnd - transitionStart)),
|
||||
);
|
||||
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
|
||||
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
|
||||
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
|
||||
const currentFocus = getResolvedFocus(currentRegion, currentScale);
|
||||
const nextFocus = getResolvedFocus(nextRegion, nextScale);
|
||||
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
|
||||
|
||||
return {
|
||||
region: {
|
||||
...nextRegion,
|
||||
focus: transitionFocus,
|
||||
},
|
||||
strength: 1,
|
||||
blendedScale: transitionScale,
|
||||
transition: {
|
||||
progress: transitionProgress,
|
||||
startFocus: currentFocus,
|
||||
endFocus: nextFocus,
|
||||
startScale: currentScale,
|
||||
endScale: nextScale,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findDominantRegion(
|
||||
regions: ZoomRegion[],
|
||||
timeMs: number,
|
||||
|
||||
Reference in New Issue
Block a user