diff --git a/src/main/index.ts b/src/main/index.ts new file mode 100644 index 0000000..0805749 --- /dev/null +++ b/src/main/index.ts @@ -0,0 +1,76 @@ +import {app, BrowserWindow, dialog, ipcMain} from 'electron'; +import path from 'node:path'; +import {IPC_CHANNELS} from '@shared/ipc'; +import type {AssetKind, StartExportInput} from '@shared/ipc'; +import {createSecureWindow, applySecurityPolicy} from './security'; +import {createProject, importAssetFile, openProject, saveProject} from './project-service'; + +const isDev = Boolean(process.env.VITE_DEV_SERVER_URL); + +const selectFile = async (filters: Electron.FileFilter[]) => { + const result = await dialog.showOpenDialog({ + properties: ['openFile'], + filters, + }); + return {canceled: result.canceled, filePath: result.filePaths[0]}; +}; + +const registerIpc = (): void => { + ipcMain.handle(IPC_CHANNELS.selectProjectDirectory, async () => { + const result = await dialog.showOpenDialog({properties: ['openDirectory', 'createDirectory']}); + return {canceled: result.canceled, filePath: result.filePaths[0]}; + }); + ipcMain.handle(IPC_CHANNELS.selectBackgroundImage, () => + selectFile([{name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp']}]), + ); + ipcMain.handle(IPC_CHANNELS.selectAudioFile, () => + selectFile([{name: 'Audio', extensions: ['mp3', 'wav', 'm4a', 'aac', 'flac', 'ogg']}]), + ); + ipcMain.handle(IPC_CHANNELS.selectProjectFile, () => + selectFile([{name: 'MV Visualizer Project', extensions: ['json']}]), + ); + ipcMain.handle(IPC_CHANNELS.createProject, (_event, input) => createProject(input)); + ipcMain.handle(IPC_CHANNELS.openProject, (_event, filePath: string) => openProject(filePath)); + ipcMain.handle(IPC_CHANNELS.saveProject, (_event, state) => saveProject(state)); + ipcMain.handle(IPC_CHANNELS.importAsset, (_event, projectDir: string, sourcePath: string, kind: AssetKind) => + importAssetFile(projectDir, sourcePath, kind), + ); + ipcMain.handle(IPC_CHANNELS.startExport, async (_event, _input: StartExportInput) => ({ + active: false, + progress: 0, + message: 'Export service is not wired yet.', + })); + ipcMain.handle(IPC_CHANNELS.cancelExport, async () => ({active: false, progress: 0, message: 'No export active.'})); + ipcMain.handle(IPC_CHANNELS.getExportStatus, async () => ({active: false, progress: 0, message: 'Idle'})); +}; + +const createWindow = async (): Promise => { + const preloadPath = path.join(app.getAppPath(), 'dist-electron/preload/index.cjs'); + const mainWindow = createSecureWindow(preloadPath); + + if (isDev && process.env.VITE_DEV_SERVER_URL) { + await mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL); + mainWindow.webContents.openDevTools({mode: 'detach'}); + } else { + await mainWindow.loadFile(path.join(app.getAppPath(), 'dist/index.html')); + } +}; + +applySecurityPolicy(); +app.whenReady().then(() => { + registerIpc(); + void createWindow(); +}); + +app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + void createWindow(); + } +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } +}); + diff --git a/src/main/project-service.ts b/src/main/project-service.ts new file mode 100644 index 0000000..9a2a634 --- /dev/null +++ b/src/main/project-service.ts @@ -0,0 +1,68 @@ +import {randomUUID} from 'node:crypto'; +import {mkdir, copyFile, readFile, writeFile} from 'node:fs/promises'; +import path from 'node:path'; +import {ASSET_DIR, PROJECT_FILE_NAME, createDefaultConfig, projectConfigSchema} from '@shared/project'; +import type {AssetKind} from '@shared/ipc'; +import type {NewProjectInput, ProjectConfig, ProjectState} from '@shared/project'; +import {normalizeRelativePath, sanitizeFileName} from '@shared/files'; + +const assetName = (sourcePath: string, kind: AssetKind): string => { + const extension = path.extname(sourcePath); + const stem = kind === 'audio' ? 'audio' : 'background'; + return `${stem}${extension.toLowerCase()}`; +}; + +const ensureProjectDirs = async (projectDir: string): Promise => { + await mkdir(path.join(projectDir, ASSET_DIR), {recursive: true}); + await mkdir(path.join(projectDir, 'exports'), {recursive: true}); +}; + +export const projectFilePath = (projectDir: string): string => path.join(projectDir, PROJECT_FILE_NAME); + +export const importAssetFile = async (projectDir: string, sourcePath: string, kind: AssetKind): Promise => { + await ensureProjectDirs(projectDir); + const fileName = assetName(sourcePath, kind); + const target = path.join(projectDir, ASSET_DIR, fileName); + await copyFile(sourcePath, target); + return normalizeRelativePath(path.relative(projectDir, target)); +}; + +export const createProject = async (input: NewProjectInput): Promise => { + await ensureProjectDirs(input.projectDir); + const backgroundImagePath = await importAssetFile(input.projectDir, input.backgroundImagePath, 'background'); + const audioPath = await importAssetFile(input.projectDir, input.audioPath, 'audio'); + const config = createDefaultConfig({ + id: randomUUID(), + locale: input.locale, + formatId: input.formatId, + backgroundImagePath, + audioPath, + artistName: input.artistName, + songName: input.songName, + }); + config.export.outputFileName = sanitizeFileName(config.export.outputFileName); + return saveProject({projectDir: input.projectDir, config}); +}; + +export const openProject = async (filePath: string): Promise => { + const raw = await readFile(filePath, 'utf8'); + const config = projectConfigSchema.parse(JSON.parse(raw)); + return { + projectDir: path.dirname(filePath), + config, + }; +}; + +export const saveProject = async (state: ProjectState): Promise => { + await ensureProjectDirs(state.projectDir); + const config: ProjectConfig = projectConfigSchema.parse({ + ...state.config, + export: { + ...state.config.export, + outputFileName: sanitizeFileName(state.config.export.outputFileName), + }, + }); + await writeFile(projectFilePath(state.projectDir), `${JSON.stringify(config, null, 2)}\n`, 'utf8'); + return {projectDir: state.projectDir, config}; +}; + diff --git a/src/main/security.ts b/src/main/security.ts new file mode 100644 index 0000000..f7692fc --- /dev/null +++ b/src/main/security.ts @@ -0,0 +1,37 @@ +import {app, BrowserWindow, shell} from 'electron'; + +export const applySecurityPolicy = (): void => { + app.on('web-contents-created', (_event, contents) => { + contents.setWindowOpenHandler(({url}) => { + if (url.startsWith('https://')) { + void shell.openExternal(url); + } + return {action: 'deny'}; + }); + + contents.on('will-navigate', (event, url) => { + const currentUrl = contents.getURL(); + if (url !== currentUrl) { + event.preventDefault(); + } + }); + }); +}; + +export const createSecureWindow = (preloadPath: string): BrowserWindow => + new BrowserWindow({ + width: 1280, + height: 860, + minWidth: 1040, + minHeight: 720, + title: 'MV Visualizer', + backgroundColor: '#0c0f14', + webPreferences: { + preload: preloadPath, + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webSecurity: true, + }, + }); + diff --git a/src/preload/index.ts b/src/preload/index.ts new file mode 100644 index 0000000..0ae7d47 --- /dev/null +++ b/src/preload/index.ts @@ -0,0 +1,22 @@ +import {contextBridge, ipcRenderer} from 'electron'; +import type {AssetKind, MvApi, StartExportInput} from '@shared/ipc'; +import {IPC_CHANNELS} from '@shared/ipc'; +import type {NewProjectInput, ProjectState} from '@shared/project'; + +const api: MvApi = { + selectProjectDirectory: () => ipcRenderer.invoke(IPC_CHANNELS.selectProjectDirectory), + selectBackgroundImage: () => ipcRenderer.invoke(IPC_CHANNELS.selectBackgroundImage), + selectAudioFile: () => ipcRenderer.invoke(IPC_CHANNELS.selectAudioFile), + selectProjectFile: () => ipcRenderer.invoke(IPC_CHANNELS.selectProjectFile), + createProject: (input: NewProjectInput) => ipcRenderer.invoke(IPC_CHANNELS.createProject, input), + openProject: (filePath: string) => ipcRenderer.invoke(IPC_CHANNELS.openProject, filePath), + saveProject: (state: ProjectState) => ipcRenderer.invoke(IPC_CHANNELS.saveProject, state), + importAsset: (projectDir: string, sourcePath: string, kind: AssetKind) => + ipcRenderer.invoke(IPC_CHANNELS.importAsset, projectDir, sourcePath, kind), + startExport: (input: StartExportInput) => ipcRenderer.invoke(IPC_CHANNELS.startExport, input), + cancelExport: () => ipcRenderer.invoke(IPC_CHANNELS.cancelExport), + getExportStatus: () => ipcRenderer.invoke(IPC_CHANNELS.getExportStatus), +}; + +contextBridge.exposeInMainWorld('mvApi', api); + diff --git a/src/renderer/vite-env.d.ts b/src/renderer/vite-env.d.ts new file mode 100644 index 0000000..868905a --- /dev/null +++ b/src/renderer/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +import type {MvApi} from '@shared/ipc'; + +declare global { + interface Window { + mvApi: MvApi; + } +} + diff --git a/src/shared/env.ts b/src/shared/env.ts new file mode 100644 index 0000000..3a4962a --- /dev/null +++ b/src/shared/env.ts @@ -0,0 +1,2 @@ +export const isDev = (): boolean => process.env.NODE_ENV === 'development' || Boolean(process.env.VITE_DEV_SERVER_URL); + diff --git a/src/shared/files.ts b/src/shared/files.ts new file mode 100644 index 0000000..4959861 --- /dev/null +++ b/src/shared/files.ts @@ -0,0 +1,20 @@ +const RESERVED_WINDOWS_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + +export const sanitizeFileName = (fileName: string): string => { + const trimmed = fileName.trim(); + const withoutControlChars = trimmed.replace(/[\u0000-\u001f\u0080-\u009f]/g, ''); + const safe = withoutControlChars + .replace(/[<>:"/\\|?*]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/\.+$/g, '') + .trim(); + + if (!safe || RESERVED_WINDOWS_NAMES.test(safe)) { + return 'mv-visualizer-export.mp4'; + } + + return safe.endsWith('.mp4') ? safe : `${safe}.mp4`; +}; + +export const normalizeRelativePath = (value: string): string => value.replaceAll('\\', '/'); + diff --git a/src/shared/formats.ts b/src/shared/formats.ts new file mode 100644 index 0000000..fa8695a --- /dev/null +++ b/src/shared/formats.ts @@ -0,0 +1,22 @@ +import {z} from 'zod'; + +export const videoFormatSchema = z.object({ + id: z.enum(['16:9', '9:16', '1:1', '4:5']), + label: z.string(), + width: z.number().int().positive(), + height: z.number().int().positive(), + fps: z.number().int().positive().default(30), +}); + +export type VideoFormat = z.infer; + +export const VIDEO_FORMATS: VideoFormat[] = [ + {id: '16:9', label: '16:9 Full HD', width: 1920, height: 1080, fps: 30}, + {id: '9:16', label: '9:16 Vertical', width: 1080, height: 1920, fps: 30}, + {id: '1:1', label: '1:1 Square', width: 1080, height: 1080, fps: 30}, + {id: '4:5', label: '4:5 Portrait', width: 1080, height: 1350, fps: 30}, +]; + +export const getVideoFormat = (id: VideoFormat['id']): VideoFormat => + VIDEO_FORMATS.find((format) => format.id === id) ?? VIDEO_FORMATS[0]; + diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts new file mode 100644 index 0000000..a2c3c1b --- /dev/null +++ b/src/shared/ipc.ts @@ -0,0 +1,50 @@ +import type {ProjectConfig, ProjectState, NewProjectInput} from './project'; + +export type AssetKind = 'background' | 'audio'; + +export type DialogFileResult = { + canceled: boolean; + filePath?: string; +}; + +export type ExportStatus = { + active: boolean; + progress: number; + message: string; + outputPath?: string; + error?: string; +}; + +export type StartExportInput = { + projectDir: string; + config: ProjectConfig; +}; + +export type MvApi = { + selectProjectDirectory: () => Promise; + selectBackgroundImage: () => Promise; + selectAudioFile: () => Promise; + selectProjectFile: () => Promise; + createProject: (input: NewProjectInput) => Promise; + openProject: (filePath: string) => Promise; + saveProject: (state: ProjectState) => Promise; + importAsset: (projectDir: string, sourcePath: string, kind: AssetKind) => Promise; + startExport: (input: StartExportInput) => Promise; + cancelExport: () => Promise; + getExportStatus: () => Promise; +}; + +export const IPC_CHANNELS = { + selectProjectDirectory: 'dialog:select-project-directory', + selectBackgroundImage: 'dialog:select-background-image', + selectAudioFile: 'dialog:select-audio-file', + selectProjectFile: 'dialog:select-project-file', + createProject: 'project:create', + openProject: 'project:open', + saveProject: 'project:save', + importAsset: 'project:import-asset', + startExport: 'export:start', + cancelExport: 'export:cancel', + getExportStatus: 'export:status', +} as const; + diff --git a/src/shared/project.ts b/src/shared/project.ts new file mode 100644 index 0000000..75695d3 --- /dev/null +++ b/src/shared/project.ts @@ -0,0 +1,105 @@ +import {z} from 'zod'; +import {getVideoFormat, videoFormatSchema} from './formats'; + +export const PROJECT_FILE_NAME = '.mvproj.json'; +export const ASSET_DIR = 'assets'; +export const EXPORT_DIR = 'exports'; +export const PROJECT_VERSION = 1; + +export const visualizerTypeSchema = z.enum(['bars', 'wave', 'ring', 'clean', 'neon', 'glitch']); +export type VisualizerType = z.infer; + +export const localeSchema = z.enum(['de', 'en']); +export type AppLocale = z.infer; + +export const projectConfigSchema = z.object({ + id: z.string().min(1), + version: z.literal(PROJECT_VERSION), + locale: localeSchema.default('de'), + format: videoFormatSchema, + assets: z.object({ + backgroundImagePath: z.string().min(1), + audioPath: z.string().min(1), + }), + metadata: z.object({ + artistName: z.string().min(1).max(120), + songName: z.string().min(1).max(160), + }), + visualizer: z.object({ + type: visualizerTypeSchema.default('bars'), + intensity: z.number().min(0.1).max(2).default(1), + smoothing: z.number().min(0).max(0.95).default(0.78), + }), + style: z.object({ + primaryColor: z.string().default('#f8fafc'), + accentColor: z.string().default('#2dd4bf'), + backgroundBlur: z.number().min(0).max(30).default(8), + overlayOpacity: z.number().min(0).max(0.85).default(0.36), + fontFamily: z.string().default('Inter, system-ui, sans-serif'), + textPosition: z.enum(['top-left', 'top-center', 'center', 'bottom-left', 'bottom-center']).default('bottom-left'), + visualizerPosition: z.enum(['bottom', 'center', 'top']).default('bottom'), + visualizerSize: z.number().min(0.2).max(1).default(0.48), + }), + export: z.object({ + codec: z.literal('h264').default('h264'), + outputFileName: z.string().min(1), + }), +}); + +export type ProjectConfig = z.infer; + +export type ProjectState = { + projectDir: string; + config: ProjectConfig; +}; + +export type NewProjectInput = { + projectDir: string; + locale: AppLocale; + formatId: ProjectConfig['format']['id']; + backgroundImagePath: string; + audioPath: string; + artistName: string; + songName: string; +}; + +export const createDefaultConfig = (input: { + id: string; + locale?: AppLocale; + formatId?: ProjectConfig['format']['id']; + backgroundImagePath: string; + audioPath: string; + artistName: string; + songName: string; +}): ProjectConfig => { + const artistName = input.artistName.trim(); + const songName = input.songName.trim(); + + return projectConfigSchema.parse({ + id: input.id, + version: PROJECT_VERSION, + locale: input.locale ?? 'de', + format: getVideoFormat(input.formatId ?? '16:9'), + assets: { + backgroundImagePath: input.backgroundImagePath, + audioPath: input.audioPath, + }, + metadata: {artistName, songName}, + visualizer: {type: 'bars', intensity: 1, smoothing: 0.78}, + style: { + primaryColor: '#f8fafc', + accentColor: '#2dd4bf', + backgroundBlur: 8, + overlayOpacity: 0.36, + fontFamily: 'Inter, system-ui, sans-serif', + textPosition: 'bottom-left', + visualizerPosition: 'bottom', + visualizerSize: 0.48, + }, + export: { + codec: 'h264', + outputFileName: `${artistName} - ${songName}.mp4`, + }, + }); +}; +