feat(ipc): persist project library metadata and tighten caption inputs

This commit is contained in:
webadderall committed 2026-03-23 19:22:40 +11:00
1 parent 91bdf8b05b
commit b4cdf7cc5e
1 file changed
+238 -56
+238 -56
View File
@@ -18,6 +18,10 @@ const nodeRequire = createRequire(import.meta.url)
const PROJECT_FILE_EXTENSION = 'recordly'
const LEGACY_PROJECT_FILE_EXTENSIONS = ['openscreen']
const PROJECTS_DIRECTORY_NAME = 'Projects'
const PROJECT_THUMBNAIL_SUFFIX = '.preview.png'
const RECENT_PROJECTS_FILE = path.join(app.getPath('userData'), 'recent-projects.json')
const MAX_RECENT_PROJECTS = 16
const SHORTCUTS_FILE = path.join(app.getPath('userData'), 'shortcuts.json')
const RECORDINGS_SETTINGS_FILE = path.join(app.getPath('userData'), 'recordings-settings.json')
const COUNTDOWN_SETTINGS_FILE = path.join(app.getPath('userData'), 'countdown-settings.json')
@@ -85,6 +89,15 @@ type RecordingSessionManifest = {
webcamFileName?: string | null
}
type ProjectLibraryEntry = {
path: string
name: string
updatedAt: number
thumbnailPath: string | null
isCurrent: boolean
isInProjectsDirectory: boolean
}
let selectedSource: SelectedSource | null = null
let currentProjectPath: string | null = null
let nativeScreenRecordingActive = false
@@ -217,6 +230,12 @@ async function getRecordingsDir() {
return targetDir
}
async function getProjectsDir() {
const projectsDir = path.join(await getRecordingsDir(), PROJECTS_DIRECTORY_NAME)
await fs.mkdir(projectsDir, { recursive: true })
return projectsDir
}
async function persistRecordingsDirectorySetting(nextDir: string) {
customRecordingsDir = path.resolve(nextDir)
recordingsDirLoaded = true
@@ -227,6 +246,154 @@ async function persistRecordingsDirectorySetting(nextDir: string) {
)
}
function hasProjectFileExtension(filePath: string) {
const extension = path.extname(filePath).replace(/^\./, '').toLowerCase()
return [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS].includes(extension)
}
function getProjectThumbnailPath(projectPath: string) {
return `${projectPath}${PROJECT_THUMBNAIL_SUFFIX}`
}
async function saveProjectThumbnail(projectPath: string, thumbnailDataUrl?: string | null) {
const thumbnailPath = getProjectThumbnailPath(projectPath)
if (!thumbnailDataUrl) {
await fs.rm(thumbnailPath, { force: true }).catch(() => undefined)
return null
}
const match = thumbnailDataUrl.match(/^data:image\/png;base64,(.+)$/)
if (!match) {
throw new Error('Project thumbnail must be a PNG data URL.')
}
await fs.writeFile(thumbnailPath, Buffer.from(match[1], 'base64'))
return thumbnailPath
}
async function loadRecentProjectPaths() {
try {
const content = await fs.readFile(RECENT_PROJECTS_FILE, 'utf-8')
const parsed = JSON.parse(content) as { paths?: unknown }
return Array.isArray(parsed.paths)
? parsed.paths.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
: []
} catch {
return []
}
}
async function saveRecentProjectPaths(paths: string[]) {
const normalizedPaths = Array.from(new Set(paths.map((value) => normalizePath(value)))).slice(0, MAX_RECENT_PROJECTS)
await fs.writeFile(
RECENT_PROJECTS_FILE,
JSON.stringify({ paths: normalizedPaths }, null, 2),
'utf-8',
)
}
async function rememberRecentProject(projectPath: string) {
if (!hasProjectFileExtension(projectPath)) {
return
}
const existingPaths = await loadRecentProjectPaths()
await saveRecentProjectPaths([projectPath, ...existingPaths])
}
async function buildProjectLibraryEntry(projectPath: string, projectsDir: string): Promise<ProjectLibraryEntry | null> {
try {
const normalizedPath = normalizePath(projectPath)
if (!hasProjectFileExtension(normalizedPath)) {
return null
}
const stats = await fs.stat(normalizedPath)
if (!stats.isFile()) {
return null
}
const thumbnailPath = getProjectThumbnailPath(normalizedPath)
const thumbnailExists = await fs.access(thumbnailPath, fsConstants.R_OK)
.then(() => true)
.catch(() => false)
return {
path: normalizedPath,
name: path.basename(normalizedPath).replace(/\.(recordly|openscreen)$/i, ''),
updatedAt: stats.mtimeMs,
thumbnailPath: thumbnailExists ? thumbnailPath : null,
isCurrent: Boolean(currentProjectPath && normalizePath(currentProjectPath) === normalizedPath),
isInProjectsDirectory: path.dirname(normalizedPath) === normalizePath(projectsDir),
}
} catch {
return null
}
}
async function listProjectLibraryEntries() {
const projectsDir = await getProjectsDir()
const projectPaths: string[] = []
try {
const entries = await fs.readdir(projectsDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isFile()) {
continue
}
const entryPath = path.join(projectsDir, entry.name)
if (hasProjectFileExtension(entryPath)) {
projectPaths.push(entryPath)
}
}
} catch {
// Ignore directory read failures and fall back to recent files.
}
const recentProjectPaths = await loadRecentProjectPaths()
const candidatePaths = Array.from(new Set([...projectPaths, ...recentProjectPaths]))
const entries = (await Promise.all(candidatePaths.map((candidatePath) => buildProjectLibraryEntry(candidatePath, projectsDir))))
.filter((entry): entry is ProjectLibraryEntry => entry != null)
.sort((left, right) => right.updatedAt - left.updatedAt)
await saveRecentProjectPaths(entries.map((entry) => entry.path))
return {
projectsDir,
entries,
}
}
async function loadProjectFromPath(projectPath: string) {
const normalizedPath = normalizePath(projectPath)
const content = await fs.readFile(normalizedPath, 'utf-8')
const project = JSON.parse(content)
const mediaSources = await resolveProjectMediaSources(project)
if (!mediaSources.success) {
return {
success: false,
canceled: false,
message: mediaSources.message,
}
}
currentProjectPath = normalizedPath
currentVideoPath = mediaSources.videoPath
currentRecordingSession = {
videoPath: mediaSources.videoPath,
webcamPath: mediaSources.webcamPath,
}
await rememberRecentProject(normalizedPath)
return {
success: true,
path: normalizedPath,
project,
}
}
function normalizeVideoSourcePath(videoPath?: string | null): string | null {
if (typeof videoPath !== 'string') {
return null
@@ -1182,10 +1349,6 @@ async function resolveCaptionAudioCandidates(videoPath: string) {
const requestedRecordingSession = await resolveRecordingSession(videoPath)
pushCandidate(requestedRecordingSession?.webcamPath, 'linked webcam recording')
pushCandidate(currentRecordingSession?.videoPath, 'current recording')
pushCandidate(currentRecordingSession?.webcamPath, 'current linked webcam recording')
pushCandidate(currentVideoPath, 'current video')
return candidates
}
@@ -3905,9 +4068,9 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
}
})
ipcMain.handle('save-project-file', async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
ipcMain.handle('save-project-file', async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string, thumbnailDataUrl?: string | null) => {
try {
const recordingsDir = await getRecordingsDir()
const projectsDir = await getProjectsDir()
const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath)
? existingProjectPath
: null
@@ -3915,6 +4078,8 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
if (trustedExistingProjectPath) {
await fs.writeFile(trustedExistingProjectPath, JSON.stringify(projectData, null, 2), 'utf-8')
currentProjectPath = trustedExistingProjectPath
await saveProjectThumbnail(trustedExistingProjectPath, thumbnailDataUrl)
await rememberRecentProject(trustedExistingProjectPath)
return {
success: true,
path: trustedExistingProjectPath,
@@ -3929,7 +4094,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
const result = await dialog.showSaveDialog({
title: 'Save Recordly Project',
defaultPath: path.join(recordingsDir, defaultName),
defaultPath: path.join(projectsDir, defaultName),
filters: [
{ name: 'Recordly Project', extensions: [PROJECT_FILE_EXTENSION] },
{ name: 'JSON', extensions: ['json'] }
@@ -3947,6 +4112,8 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
await fs.writeFile(result.filePath, JSON.stringify(projectData, null, 2), 'utf-8')
currentProjectPath = result.filePath
await saveProjectThumbnail(result.filePath, thumbnailDataUrl)
await rememberRecentProject(result.filePath)
return {
success: true,
@@ -3965,10 +4132,10 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
ipcMain.handle('load-project-file', async () => {
try {
const recordingsDir = await getRecordingsDir()
const projectsDir = await getProjectsDir()
const result = await dialog.showOpenDialog({
title: 'Open Recordly Project',
defaultPath: recordingsDir,
defaultPath: projectsDir,
filters: [
{ name: 'Recordly Project', extensions: [PROJECT_FILE_EXTENSION, ...LEGACY_PROJECT_FILE_EXTENSIONS] },
{ name: 'JSON', extensions: ['json'] },
@@ -3981,31 +4148,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
return { success: false, canceled: true, message: 'Open project canceled' }
}
const filePath = result.filePaths[0]
const content = await fs.readFile(filePath, 'utf-8')
const project = JSON.parse(content)
const mediaSources = await resolveProjectMediaSources(project)
if (!mediaSources.success) {
return {
success: false,
canceled: false,
message: mediaSources.message,
}
}
currentProjectPath = filePath
currentVideoPath = mediaSources.videoPath
currentRecordingSession = {
videoPath: mediaSources.videoPath,
webcamPath: mediaSources.webcamPath,
}
return {
success: true,
path: filePath,
project
}
return await loadProjectFromPath(result.filePaths[0])
} catch (error) {
console.error('Failed to load project file:', error)
return {
@@ -4022,28 +4165,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
return { success: false, message: 'No active project' }
}
const content = await fs.readFile(currentProjectPath, 'utf-8')
const project = JSON.parse(content)
const mediaSources = await resolveProjectMediaSources(project)
if (!mediaSources.success) {
return {
success: false,
message: mediaSources.message,
}
}
currentVideoPath = mediaSources.videoPath
currentRecordingSession = {
videoPath: mediaSources.videoPath,
webcamPath: mediaSources.webcamPath,
}
return {
success: true,
path: currentProjectPath,
project,
}
return await loadProjectFromPath(currentProjectPath)
} catch (error) {
console.error('Failed to load current project file:', error)
return {
@@ -4053,6 +4175,66 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
}
}
})
ipcMain.handle('get-projects-directory', async () => {
try {
return {
success: true,
path: await getProjectsDir(),
}
} catch (error) {
return {
success: false,
error: String(error),
}
}
})
ipcMain.handle('list-project-files', async () => {
try {
const library = await listProjectLibraryEntries()
return {
success: true,
projectsDir: library.projectsDir,
entries: library.entries,
}
} catch (error) {
return {
success: false,
projectsDir: null,
entries: [],
error: String(error),
}
}
})
ipcMain.handle('open-project-file-at-path', async (_, filePath: string) => {
try {
return await loadProjectFromPath(filePath)
} catch (error) {
console.error('Failed to open project file at path:', error)
return {
success: false,
message: 'Failed to open project file',
error: String(error),
}
}
})
ipcMain.handle('open-projects-directory', async () => {
try {
const projectsDir = await getProjectsDir()
const openPathResult = await shell.openPath(projectsDir)
if (openPathResult) {
return { success: false, error: openPathResult, message: 'Failed to open projects folder.' }
}
return { success: true, path: projectsDir }
} catch (error) {
console.error('Failed to open projects folder:', error)
return { success: false, error: String(error), message: 'Failed to open projects folder.' }
}
})
ipcMain.handle('set-current-video-path', async (_, path: string) => {
currentVideoPath = normalizeVideoSourcePath(path) ?? path
const resolvedSession = await resolveRecordingSession(currentVideoPath)