biome linting refactor

This commit is contained in:
Siddharth
2026-03-07 17:59:41 -08:00
parent 555b199e03
commit 885d66c4a4
96 changed files with 14041 additions and 13373 deletions
+6 -36
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": { "ignoreUnknown": false },
"files": { "ignoreUnknown": false, "includes": ["**", "!**/*.css"] },
"formatter": {
"enabled": true,
"indentStyle": "tab",
@@ -51,10 +51,10 @@
"useYield": "error"
},
"style": {
"noNamespace": "error",
"noNamespace": "off",
"useArrayLiterals": "error",
"useAsConstAssertion": "error",
"useComponentExportOnlyModules": "warn"
"useComponentExportOnlyModules": "off"
},
"suspicious": {
"noAssignInExpressions": "error",
@@ -69,8 +69,8 @@
"noDuplicateElseIf": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "error",
"noExplicitAny": "error",
"noEmptyBlockStatements": "warn",
"noExplicitAny": "warn",
"noExtraNonNullAssertion": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
@@ -92,40 +92,10 @@
"useGetterReturn": "error"
}
},
"includes": ["**", "**/dist", "**/.eslintrc.cjs", "**", "**/dist", "**/.eslintrc.cjs"]
"includes": ["**", "**/dist", "**/.eslintrc.cjs", "!**/*.css"]
},
"javascript": { "formatter": { "quoteStyle": "double" } },
"overrides": [
{
"includes": ["*.ts", "*.tsx", "*.mts", "*.cts"],
"linter": {
"rules": {
"complexity": { "noArguments": "error" },
"correctness": {
"noConstAssign": "off",
"noGlobalObjectCalls": "off",
"noInvalidBuiltinInstantiation": "off",
"noInvalidConstructorSuper": "off",
"noSetterReturn": "off",
"noUndeclaredVariables": "off",
"noUnreachable": "off",
"noUnreachableSuper": "off"
},
"style": { "useConst": "error" },
"suspicious": {
"noDuplicateClassMembers": "off",
"noDuplicateObjectKeys": "off",
"noDuplicateParameters": "off",
"noFunctionAssign": "off",
"noImportAssign": "off",
"noRedeclare": "off",
"noUnsafeNegation": "off",
"noVar": "error",
"useGetterReturn": "off"
}
}
}
},
{
"includes": ["*.ts", "*.tsx", "*.mts", "*.cts"],
"linter": {
+20 -20
View File
@@ -1,22 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.cjs",
"css": "src/index.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.cjs",
"css": "src/index.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+108 -71
View File
@@ -1,71 +1,108 @@
/// <reference types="vite-plugin-electron/electron-env" />
declare namespace NodeJS {
interface ProcessEnv {
/**
* The built directory structure
*
* ```tree
* ├─┬─┬ dist
* │ │ └── index.html
* │ │
* │ ├─┬ dist-electron
* │ │ ├── main.js
* │ │ └── preload.js
* │
* ```
*/
APP_ROOT: string
/** /dist/ or /public/ */
VITE_PUBLIC: string
}
}
// Used in Renderer process, expose in `preload.ts`
interface Window {
electronAPI: {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>
switchToEditor: () => Promise<void>
openSourceSelector: () => Promise<void>
selectSource: (source: any) => Promise<any>
getSelectedSource: () => Promise<any>
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }>
getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>
setRecordingState: (recording: boolean) => Promise<void>
getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; samples: CursorTelemetryPoint[]; message?: string; error?: string }>
onStopRecordingFromTray: (callback: () => void) => () => void
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>
clearCurrentVideoPath: () => Promise<{ success: boolean }>
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean; error?: string }>
loadProjectFile: () => Promise<{ success: boolean; path?: string; project?: unknown; message?: string; canceled?: boolean; error?: string }>
loadCurrentProjectFile: () => Promise<{ success: boolean; path?: string; project?: unknown; message?: string; canceled?: boolean; error?: string }>
onMenuLoadProject: (callback: () => void) => () => void
onMenuSaveProject: (callback: () => void) => () => void
onMenuSaveProjectAs: (callback: () => void) => () => void
getPlatform: () => Promise<string>
revealInFolder: (filePath: string) => Promise<{ success: boolean; error?: string; message?: string }>,
getShortcuts: () => Promise<Record<string, unknown> | null>
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>
hudOverlayHide: () => void;
hudOverlayClose: () => void;
setMicrophoneExpanded: (expanded: boolean) => void;
}
}
interface ProcessedDesktopSource {
id: string
name: string
display_id: string
thumbnail: string | null
appIcon: string | null
}
interface CursorTelemetryPoint {
timeMs: number
cx: number
cy: number
}
/// <reference types="vite-plugin-electron/electron-env" />
declare namespace NodeJS {
interface ProcessEnv {
/**
* The built directory structure
*
* ```tree
* ├─┬─┬ dist
* │ │ └── index.html
* │ │
* │ ├─┬ dist-electron
* │ │ ├── main.js
* │ │ └── preload.js
* │
* ```
*/
APP_ROOT: string;
/** /dist/ or /public/ */
VITE_PUBLIC: string;
}
}
// Used in Renderer process, expose in `preload.ts`
interface Window {
electronAPI: {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
openSourceSelector: () => Promise<void>;
selectSource: (source: any) => Promise<any>;
getSelectedSource: () => Promise<any>;
storeRecordedVideo: (
videoData: ArrayBuffer,
fileName: string,
) => Promise<{ success: boolean; path?: string; message?: string }>;
getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>;
setRecordingState: (recording: boolean) => Promise<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
samples: CursorTelemetryPoint[];
message?: string;
error?: string;
}>;
onStopRecordingFromTray: (callback: () => void) => () => void;
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
saveExportedVideo: (
videoData: ArrayBuffer,
fileName: string,
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
saveProjectFile: (
projectData: unknown,
suggestedName?: string,
existingProjectPath?: string,
) => Promise<{
success: boolean;
path?: string;
message?: string;
canceled?: boolean;
error?: string;
}>;
loadProjectFile: () => Promise<{
success: boolean;
path?: string;
project?: unknown;
message?: string;
canceled?: boolean;
error?: string;
}>;
loadCurrentProjectFile: () => Promise<{
success: boolean;
path?: string;
project?: unknown;
message?: string;
canceled?: boolean;
error?: string;
}>;
onMenuLoadProject: (callback: () => void) => () => void;
onMenuSaveProject: (callback: () => void) => () => void;
onMenuSaveProjectAs: (callback: () => void) => () => void;
getPlatform: () => Promise<string>;
revealInFolder: (
filePath: string,
) => Promise<{ success: boolean; error?: string; message?: string }>;
getShortcuts: () => Promise<Record<string, unknown> | null>;
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
hudOverlayHide: () => void;
hudOverlayClose: () => void;
setMicrophoneExpanded: (expanded: boolean) => void;
};
}
interface ProcessedDesktopSource {
id: string;
name: string;
display_id: string;
thumbnail: string | null;
appIcon: string | null;
}
interface CursorTelemetryPoint {
timeMs: number;
cx: number;
cy: number;
}
+464 -443
View File
@@ -1,520 +1,541 @@
import { ipcMain, desktopCapturer, BrowserWindow, shell, app, dialog, screen } from 'electron'
import fs from "node:fs/promises";
import path from "node:path";
import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, screen, shell } from "electron";
import { RECORDINGS_DIR } from "../main";
import fs from 'node:fs/promises'
import path from 'node:path'
import { RECORDINGS_DIR } from '../main'
const PROJECT_FILE_EXTENSION = 'openscreen'
const SHORTCUTS_FILE = path.join(app.getPath('userData'), 'shortcuts.json')
const PROJECT_FILE_EXTENSION = "openscreen";
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
type SelectedSource = {
name: string
[key: string]: unknown
}
name: string;
[key: string]: unknown;
};
let selectedSource: SelectedSource | null = null
let currentVideoPath: string | null = null
let currentProjectPath: string | null = null
let selectedSource: SelectedSource | null = null;
let currentVideoPath: string | null = null;
let currentProjectPath: string | null = null;
function normalizePath(filePath: string) {
return path.resolve(filePath)
return path.resolve(filePath);
}
function isTrustedProjectPath(filePath?: string | null) {
if (!filePath || !currentProjectPath) {
return false
}
return normalizePath(filePath) === normalizePath(currentProjectPath)
if (!filePath || !currentProjectPath) {
return false;
}
return normalizePath(filePath) === normalizePath(currentProjectPath);
}
const CURSOR_TELEMETRY_VERSION = 1
const CURSOR_SAMPLE_INTERVAL_MS = 100
const MAX_CURSOR_SAMPLES = 60 * 60 * 10 // 1 hour @ 10Hz
const CURSOR_TELEMETRY_VERSION = 1;
const CURSOR_SAMPLE_INTERVAL_MS = 100;
const MAX_CURSOR_SAMPLES = 60 * 60 * 10; // 1 hour @ 10Hz
interface CursorTelemetryPoint {
timeMs: number
cx: number
cy: number
timeMs: number;
cx: number;
cy: number;
}
let cursorCaptureInterval: NodeJS.Timeout | null = null
let cursorCaptureStartTimeMs = 0
let activeCursorSamples: CursorTelemetryPoint[] = []
let pendingCursorSamples: CursorTelemetryPoint[] = []
let cursorCaptureInterval: NodeJS.Timeout | null = null;
let cursorCaptureStartTimeMs = 0;
let activeCursorSamples: CursorTelemetryPoint[] = [];
let pendingCursorSamples: CursorTelemetryPoint[] = [];
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
return Math.min(max, Math.max(min, value));
}
function stopCursorCapture() {
if (cursorCaptureInterval) {
clearInterval(cursorCaptureInterval)
cursorCaptureInterval = null
}
if (cursorCaptureInterval) {
clearInterval(cursorCaptureInterval);
cursorCaptureInterval = null;
}
}
function sampleCursorPoint() {
const cursor = screen.getCursorScreenPoint()
const sourceDisplayId = Number(selectedSource?.display_id)
const sourceDisplay = Number.isFinite(sourceDisplayId)
? screen.getAllDisplays().find((display) => display.id === sourceDisplayId) ?? null
: null
const display = sourceDisplay ?? screen.getDisplayNearestPoint(cursor)
const bounds = display.bounds
const width = Math.max(1, bounds.width)
const height = Math.max(1, bounds.height)
const cursor = screen.getCursorScreenPoint();
const sourceDisplayId = Number(selectedSource?.display_id);
const sourceDisplay = Number.isFinite(sourceDisplayId)
? (screen.getAllDisplays().find((display) => display.id === sourceDisplayId) ?? null)
: null;
const display = sourceDisplay ?? screen.getDisplayNearestPoint(cursor);
const bounds = display.bounds;
const width = Math.max(1, bounds.width);
const height = Math.max(1, bounds.height);
const cx = clamp((cursor.x - bounds.x) / width, 0, 1)
const cy = clamp((cursor.y - bounds.y) / height, 0, 1)
const cx = clamp((cursor.x - bounds.x) / width, 0, 1);
const cy = clamp((cursor.y - bounds.y) / height, 0, 1);
activeCursorSamples.push({
timeMs: Math.max(0, Date.now() - cursorCaptureStartTimeMs),
cx,
cy,
})
activeCursorSamples.push({
timeMs: Math.max(0, Date.now() - cursorCaptureStartTimeMs),
cx,
cy,
});
if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) {
activeCursorSamples.shift()
}
if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) {
activeCursorSamples.shift();
}
}
export function registerIpcHandlers(
createEditorWindow: () => void,
createSourceSelectorWindow: () => BrowserWindow,
getMainWindow: () => BrowserWindow | null,
getSourceSelectorWindow: () => BrowserWindow | null,
onRecordingStateChange?: (recording: boolean, sourceName: string) => void
createEditorWindow: () => void,
createSourceSelectorWindow: () => BrowserWindow,
getMainWindow: () => BrowserWindow | null,
getSourceSelectorWindow: () => BrowserWindow | null,
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
) {
ipcMain.handle('get-sources', async (_, opts) => {
const sources = await desktopCapturer.getSources(opts)
return sources.map(source => ({
id: source.id,
name: source.name,
display_id: source.display_id,
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
appIcon: source.appIcon ? source.appIcon.toDataURL() : null
}))
})
ipcMain.handle("get-sources", async (_, opts) => {
const sources = await desktopCapturer.getSources(opts);
return sources.map((source) => ({
id: source.id,
name: source.name,
display_id: source.display_id,
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
appIcon: source.appIcon ? source.appIcon.toDataURL() : null,
}));
});
ipcMain.handle('select-source', (_, source: SelectedSource) => {
selectedSource = source
const sourceSelectorWin = getSourceSelectorWindow()
if (sourceSelectorWin) {
sourceSelectorWin.close()
}
return selectedSource
})
ipcMain.handle("select-source", (_, source: SelectedSource) => {
selectedSource = source;
const sourceSelectorWin = getSourceSelectorWindow();
if (sourceSelectorWin) {
sourceSelectorWin.close();
}
return selectedSource;
});
ipcMain.handle('get-selected-source', () => {
return selectedSource
})
ipcMain.handle("get-selected-source", () => {
return selectedSource;
});
ipcMain.handle('open-source-selector', () => {
const sourceSelectorWin = getSourceSelectorWindow()
if (sourceSelectorWin) {
sourceSelectorWin.focus()
return
}
createSourceSelectorWindow()
})
ipcMain.handle("open-source-selector", () => {
const sourceSelectorWin = getSourceSelectorWindow();
if (sourceSelectorWin) {
sourceSelectorWin.focus();
return;
}
createSourceSelectorWindow();
});
ipcMain.handle('switch-to-editor', () => {
const mainWin = getMainWindow()
if (mainWin) {
mainWin.close()
}
createEditorWindow()
})
ipcMain.handle("switch-to-editor", () => {
const mainWin = getMainWindow();
if (mainWin) {
mainWin.close();
}
createEditorWindow();
});
ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: string) => {
try {
const videoPath = path.join(RECORDINGS_DIR, fileName);
await fs.writeFile(videoPath, Buffer.from(videoData));
currentVideoPath = videoPath;
currentProjectPath = null;
const telemetryPath = `${videoPath}.cursor.json`;
if (pendingCursorSamples.length > 0) {
await fs.writeFile(
telemetryPath,
JSON.stringify(
{ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples },
null,
2,
),
"utf-8",
);
}
pendingCursorSamples = [];
ipcMain.handle('store-recorded-video', async (_, videoData: ArrayBuffer, fileName: string) => {
try {
const videoPath = path.join(RECORDINGS_DIR, fileName)
await fs.writeFile(videoPath, Buffer.from(videoData))
currentVideoPath = videoPath;
currentProjectPath = null
return {
success: true,
path: videoPath,
message: "Video stored successfully",
};
} catch (error) {
console.error("Failed to store video:", error);
return {
success: false,
message: "Failed to store video",
error: String(error),
};
}
});
const telemetryPath = `${videoPath}.cursor.json`
if (pendingCursorSamples.length > 0) {
await fs.writeFile(
telemetryPath,
JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, null, 2),
'utf-8'
)
}
pendingCursorSamples = []
ipcMain.handle("get-recorded-video-path", async () => {
try {
const files = await fs.readdir(RECORDINGS_DIR);
const videoFiles = files.filter((file) => file.endsWith(".webm"));
return {
success: true,
path: videoPath,
message: 'Video stored successfully'
}
} catch (error) {
console.error('Failed to store video:', error)
return {
success: false,
message: 'Failed to store video',
error: String(error)
}
}
})
if (videoFiles.length === 0) {
return { success: false, message: "No recorded video found" };
}
const latestVideo = videoFiles.sort().reverse()[0];
const videoPath = path.join(RECORDINGS_DIR, latestVideo);
return { success: true, path: videoPath };
} catch (error) {
console.error("Failed to get video path:", error);
return { success: false, message: "Failed to get video path", error: String(error) };
}
});
ipcMain.handle('get-recorded-video-path', async () => {
try {
const files = await fs.readdir(RECORDINGS_DIR)
const videoFiles = files.filter(file => file.endsWith('.webm'))
if (videoFiles.length === 0) {
return { success: false, message: 'No recorded video found' }
}
const latestVideo = videoFiles.sort().reverse()[0]
const videoPath = path.join(RECORDINGS_DIR, latestVideo)
return { success: true, path: videoPath }
} catch (error) {
console.error('Failed to get video path:', error)
return { success: false, message: 'Failed to get video path', error: String(error) }
}
})
ipcMain.handle("set-recording-state", (_, recording: boolean) => {
if (recording) {
stopCursorCapture();
activeCursorSamples = [];
pendingCursorSamples = [];
cursorCaptureStartTimeMs = Date.now();
sampleCursorPoint();
cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS);
} else {
stopCursorCapture();
pendingCursorSamples = [...activeCursorSamples];
activeCursorSamples = [];
}
ipcMain.handle('set-recording-state', (_, recording: boolean) => {
if (recording) {
stopCursorCapture()
activeCursorSamples = []
pendingCursorSamples = []
cursorCaptureStartTimeMs = Date.now()
sampleCursorPoint()
cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS)
} else {
stopCursorCapture()
pendingCursorSamples = [...activeCursorSamples]
activeCursorSamples = []
}
const source = selectedSource || { name: "Screen" };
if (onRecordingStateChange) {
onRecordingStateChange(recording, source.name);
}
});
const source = selectedSource || { name: 'Screen' }
if (onRecordingStateChange) {
onRecordingStateChange(recording, source.name)
}
})
ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => {
const targetVideoPath = videoPath ?? currentVideoPath;
if (!targetVideoPath) {
return { success: true, samples: [] };
}
ipcMain.handle('get-cursor-telemetry', async (_, videoPath?: string) => {
const targetVideoPath = videoPath ?? currentVideoPath
if (!targetVideoPath) {
return { success: true, samples: [] }
}
const telemetryPath = `${targetVideoPath}.cursor.json`;
try {
const content = await fs.readFile(telemetryPath, "utf-8");
const parsed = JSON.parse(content);
const rawSamples = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.samples)
? parsed.samples
: [];
const telemetryPath = `${targetVideoPath}.cursor.json`
try {
const content = await fs.readFile(telemetryPath, 'utf-8')
const parsed = JSON.parse(content)
const rawSamples = Array.isArray(parsed)
? parsed
: (Array.isArray(parsed?.samples) ? parsed.samples : [])
const samples: CursorTelemetryPoint[] = rawSamples
.filter((sample: unknown) => Boolean(sample && typeof sample === "object"))
.map((sample: unknown) => {
const point = sample as Partial<CursorTelemetryPoint>;
return {
timeMs:
typeof point.timeMs === "number" && Number.isFinite(point.timeMs)
? Math.max(0, point.timeMs)
: 0,
cx:
typeof point.cx === "number" && Number.isFinite(point.cx)
? clamp(point.cx, 0, 1)
: 0.5,
cy:
typeof point.cy === "number" && Number.isFinite(point.cy)
? clamp(point.cy, 0, 1)
: 0.5,
};
})
.sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs);
const samples: CursorTelemetryPoint[] = rawSamples
.filter((sample: unknown) => Boolean(sample && typeof sample === 'object'))
.map((sample: unknown) => {
const point = sample as Partial<CursorTelemetryPoint>
return {
timeMs: typeof point.timeMs === 'number' && Number.isFinite(point.timeMs) ? Math.max(0, point.timeMs) : 0,
cx: typeof point.cx === 'number' && Number.isFinite(point.cx) ? clamp(point.cx, 0, 1) : 0.5,
cy: typeof point.cy === 'number' && Number.isFinite(point.cy) ? clamp(point.cy, 0, 1) : 0.5,
}
})
.sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs)
return { success: true, samples };
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "ENOENT") {
return { success: true, samples: [] };
}
console.error("Failed to load cursor telemetry:", error);
return {
success: false,
message: "Failed to load cursor telemetry",
error: String(error),
samples: [],
};
}
});
return { success: true, samples }
} catch (error) {
const nodeError = error as NodeJS.ErrnoException
if (nodeError.code === 'ENOENT') {
return { success: true, samples: [] }
}
console.error('Failed to load cursor telemetry:', error)
return { success: false, message: 'Failed to load cursor telemetry', error: String(error), samples: [] }
}
})
ipcMain.handle("open-external-url", async (_, url: string) => {
try {
await shell.openExternal(url);
return { success: true };
} catch (error) {
console.error("Failed to open URL:", error);
return { success: false, error: String(error) };
}
});
// Return base path for assets so renderer can resolve file:// paths in production
ipcMain.handle("get-asset-base-path", () => {
try {
if (app.isPackaged) {
return path.join(process.resourcesPath, "assets");
}
return path.join(app.getAppPath(), "public", "assets");
} catch (err) {
console.error("Failed to resolve asset base path:", err);
return null;
}
});
ipcMain.handle('open-external-url', async (_, url: string) => {
try {
await shell.openExternal(url)
return { success: true }
} catch (error) {
console.error('Failed to open URL:', error)
return { success: false, error: String(error) }
}
})
ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer, fileName: string) => {
try {
// Determine file type from extension
const isGif = fileName.toLowerCase().endsWith(".gif");
const filters = isGif
? [{ name: "GIF Image", extensions: ["gif"] }]
: [{ name: "MP4 Video", extensions: ["mp4"] }];
// Return base path for assets so renderer can resolve file:// paths in production
ipcMain.handle('get-asset-base-path', () => {
try {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'assets')
}
return path.join(app.getAppPath(), 'public', 'assets')
} catch (err) {
console.error('Failed to resolve asset base path:', err)
return null
}
})
const result = await dialog.showSaveDialog({
title: isGif ? "Save Exported GIF" : "Save Exported Video",
defaultPath: path.join(app.getPath("downloads"), fileName),
filters,
properties: ["createDirectory", "showOverwriteConfirmation"],
});
ipcMain.handle('save-exported-video', async (_, videoData: ArrayBuffer, fileName: string) => {
try {
// Determine file type from extension
const isGif = fileName.toLowerCase().endsWith('.gif');
const filters = isGif
? [{ name: 'GIF Image', extensions: ['gif'] }]
: [{ name: 'MP4 Video', extensions: ['mp4'] }];
if (result.canceled || !result.filePath) {
return {
success: false,
canceled: true,
message: "Export canceled",
};
}
const result = await dialog.showSaveDialog({
title: isGif ? 'Save Exported GIF' : 'Save Exported Video',
defaultPath: path.join(app.getPath('downloads'), fileName),
filters,
properties: ['createDirectory', 'showOverwriteConfirmation']
});
await fs.writeFile(result.filePath, Buffer.from(videoData));
if (result.canceled || !result.filePath) {
return {
success: false,
canceled: true,
message: 'Export canceled'
};
}
return {
success: true,
path: result.filePath,
message: "Video exported successfully",
};
} catch (error) {
console.error("Failed to save exported video:", error);
return {
success: false,
message: "Failed to save exported video",
error: String(error),
};
}
});
await fs.writeFile(result.filePath, Buffer.from(videoData));
ipcMain.handle("open-video-file-picker", async () => {
try {
const result = await dialog.showOpenDialog({
title: "Select Video File",
defaultPath: RECORDINGS_DIR,
filters: [
{ name: "Video Files", extensions: ["webm", "mp4", "mov", "avi", "mkv"] },
{ name: "All Files", extensions: ["*"] },
],
properties: ["openFile"],
});
return {
success: true,
path: result.filePath,
message: 'Video exported successfully'
};
} catch (error) {
console.error('Failed to save exported video:', error)
return {
success: false,
message: 'Failed to save exported video',
error: String(error)
}
}
})
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true };
}
ipcMain.handle('open-video-file-picker', async () => {
try {
const result = await dialog.showOpenDialog({
title: 'Select Video File',
defaultPath: RECORDINGS_DIR,
filters: [
{ name: 'Video Files', extensions: ['webm', 'mp4', 'mov', 'avi', 'mkv'] },
{ name: 'All Files', extensions: ['*'] }
],
properties: ['openFile']
});
currentProjectPath = 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),
};
}
});
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true };
}
ipcMain.handle("reveal-in-folder", async (_, filePath: string) => {
try {
// shell.showItemInFolder doesn't return a value, it throws on error
shell.showItemInFolder(filePath);
return { success: true };
} catch (error) {
console.error(`Error revealing item in folder: ${filePath}`, error);
// Fallback to open the directory if revealing the item fails
// This might happen if the file was moved or deleted after export,
// or if the path is somehow invalid for showItemInFolder
try {
const openPathResult = await shell.openPath(path.dirname(filePath));
if (openPathResult) {
// openPath returned an error message
return { success: false, error: openPathResult };
}
return { success: true, message: "Could not reveal item, but opened directory." };
} catch (openError) {
console.error(`Error opening directory: ${path.dirname(filePath)}`, openError);
return { success: false, error: String(error) };
}
}
});
currentProjectPath = 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)
};
}
});
let currentVideoPath: string | null = null;
ipcMain.handle(
"save-project-file",
async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
try {
const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath)
? existingProjectPath
: null;
ipcMain.handle('reveal-in-folder', async (_, filePath: string) => {
try {
// shell.showItemInFolder doesn't return a value, it throws on error
shell.showItemInFolder(filePath);
return { success: true };
} catch (error) {
console.error(`Error revealing item in folder: ${filePath}`, error);
// Fallback to open the directory if revealing the item fails
// This might happen if the file was moved or deleted after export,
// or if the path is somehow invalid for showItemInFolder
try {
const openPathResult = await shell.openPath(path.dirname(filePath));
if (openPathResult) {
// openPath returned an error message
return { success: false, error: openPathResult };
}
return { success: true, message: 'Could not reveal item, but opened directory.' };
} catch (openError) {
console.error(`Error opening directory: ${path.dirname(filePath)}`, openError);
return { success: false, error: String(error) };
}
}
});
if (trustedExistingProjectPath) {
await fs.writeFile(
trustedExistingProjectPath,
JSON.stringify(projectData, null, 2),
"utf-8",
);
currentProjectPath = trustedExistingProjectPath;
return {
success: true,
path: trustedExistingProjectPath,
message: "Project saved successfully",
};
}
let currentVideoPath: string | null = null;
ipcMain.handle('save-project-file', async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
try {
const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath)
? existingProjectPath
: null
const safeName = (suggestedName || `project-${Date.now()}`).replace(/[^a-zA-Z0-9-_]/g, "_");
const defaultName = safeName.endsWith(`.${PROJECT_FILE_EXTENSION}`)
? safeName
: `${safeName}.${PROJECT_FILE_EXTENSION}`;
if (trustedExistingProjectPath) {
await fs.writeFile(trustedExistingProjectPath, JSON.stringify(projectData, null, 2), 'utf-8')
currentProjectPath = trustedExistingProjectPath
return {
success: true,
path: trustedExistingProjectPath,
message: 'Project saved successfully'
}
}
const result = await dialog.showSaveDialog({
title: "Save OpenScreen Project",
defaultPath: path.join(RECORDINGS_DIR, defaultName),
filters: [
{ name: "OpenScreen Project", extensions: [PROJECT_FILE_EXTENSION] },
{ name: "JSON", extensions: ["json"] },
],
properties: ["createDirectory", "showOverwriteConfirmation"],
});
const safeName = (suggestedName || `project-${Date.now()}`).replace(/[^a-zA-Z0-9-_]/g, '_')
const defaultName = safeName.endsWith(`.${PROJECT_FILE_EXTENSION}`)
? safeName
: `${safeName}.${PROJECT_FILE_EXTENSION}`
if (result.canceled || !result.filePath) {
return {
success: false,
canceled: true,
message: "Save project canceled",
};
}
const result = await dialog.showSaveDialog({
title: 'Save OpenScreen Project',
defaultPath: path.join(RECORDINGS_DIR, defaultName),
filters: [
{ name: 'OpenScreen Project', extensions: [PROJECT_FILE_EXTENSION] },
{ name: 'JSON', extensions: ['json'] }
],
properties: ['createDirectory', 'showOverwriteConfirmation']
})
await fs.writeFile(result.filePath, JSON.stringify(projectData, null, 2), "utf-8");
currentProjectPath = result.filePath;
if (result.canceled || !result.filePath) {
return {
success: false,
canceled: true,
message: 'Save project canceled'
}
}
return {
success: true,
path: result.filePath,
message: "Project saved successfully",
};
} catch (error) {
console.error("Failed to save project file:", error);
return {
success: false,
message: "Failed to save project file",
error: String(error),
};
}
},
);
await fs.writeFile(result.filePath, JSON.stringify(projectData, null, 2), 'utf-8')
currentProjectPath = result.filePath
ipcMain.handle("load-project-file", async () => {
try {
const result = await dialog.showOpenDialog({
title: "Open OpenScreen Project",
defaultPath: RECORDINGS_DIR,
filters: [
{ name: "OpenScreen Project", extensions: [PROJECT_FILE_EXTENSION] },
{ name: "JSON", extensions: ["json"] },
{ name: "All Files", extensions: ["*"] },
],
properties: ["openFile"],
});
return {
success: true,
path: result.filePath,
message: 'Project saved successfully'
}
} catch (error) {
console.error('Failed to save project file:', error)
return {
success: false,
message: 'Failed to save project file',
error: String(error)
}
}
})
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true, message: "Open project canceled" };
}
ipcMain.handle('load-project-file', async () => {
try {
const result = await dialog.showOpenDialog({
title: 'Open OpenScreen Project',
defaultPath: RECORDINGS_DIR,
filters: [
{ name: 'OpenScreen Project', extensions: [PROJECT_FILE_EXTENSION] },
{ name: 'JSON', extensions: ['json'] },
{ name: 'All Files', extensions: ['*'] }
],
properties: ['openFile']
})
const filePath = result.filePaths[0];
const content = await fs.readFile(filePath, "utf-8");
const project = JSON.parse(content);
currentProjectPath = filePath;
if (project && typeof project === "object" && typeof project.videoPath === "string") {
currentVideoPath = project.videoPath;
}
if (result.canceled || result.filePaths.length === 0) {
return { success: false, canceled: true, message: 'Open project canceled' }
}
return {
success: true,
path: filePath,
project,
};
} catch (error) {
console.error("Failed to load project file:", error);
return {
success: false,
message: "Failed to load project file",
error: String(error),
};
}
});
const filePath = result.filePaths[0]
const content = await fs.readFile(filePath, 'utf-8')
const project = JSON.parse(content)
currentProjectPath = filePath
if (project && typeof project === 'object' && typeof project.videoPath === 'string') {
currentVideoPath = project.videoPath
}
ipcMain.handle("load-current-project-file", async () => {
try {
if (!currentProjectPath) {
return { success: false, message: "No active project" };
}
return {
success: true,
path: filePath,
project
}
} catch (error) {
console.error('Failed to load project file:', error)
return {
success: false,
message: 'Failed to load project file',
error: String(error)
}
}
})
const content = await fs.readFile(currentProjectPath, "utf-8");
const project = JSON.parse(content);
if (project && typeof project === "object" && typeof project.videoPath === "string") {
currentVideoPath = project.videoPath;
}
return {
success: true,
path: currentProjectPath,
project,
};
} catch (error) {
console.error("Failed to load current project file:", error);
return {
success: false,
message: "Failed to load current project file",
error: String(error),
};
}
});
ipcMain.handle("set-current-video-path", (_, path: string) => {
currentVideoPath = path;
currentProjectPath = null;
return { success: true };
});
ipcMain.handle('load-current-project-file', async () => {
try {
if (!currentProjectPath) {
return { success: false, message: 'No active project' }
}
ipcMain.handle("get-current-video-path", () => {
return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false };
});
const content = await fs.readFile(currentProjectPath, 'utf-8')
const project = JSON.parse(content)
if (project && typeof project === 'object' && typeof project.videoPath === 'string') {
currentVideoPath = project.videoPath
}
return {
success: true,
path: currentProjectPath,
project,
}
} catch (error) {
console.error('Failed to load current project file:', error)
return {
success: false,
message: 'Failed to load current project file',
error: String(error),
}
}
})
ipcMain.handle('set-current-video-path', (_, path: string) => {
currentVideoPath = path;
currentProjectPath = null
return { success: true };
});
ipcMain.handle("clear-current-video-path", () => {
currentVideoPath = null;
return { success: true };
});
ipcMain.handle('get-current-video-path', () => {
return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false };
});
ipcMain.handle("get-platform", () => {
return process.platform;
});
ipcMain.handle('clear-current-video-path', () => {
currentVideoPath = null;
return { success: true };
});
ipcMain.handle("get-shortcuts", async () => {
try {
const data = await fs.readFile(SHORTCUTS_FILE, "utf-8");
return JSON.parse(data);
} catch {
return null;
}
});
ipcMain.handle('get-platform', () => {
return process.platform;
});
ipcMain.handle('get-shortcuts', async () => {
try {
const data = await fs.readFile(SHORTCUTS_FILE, 'utf-8');
return JSON.parse(data);
} catch {
return null;
}
});
ipcMain.handle('save-shortcuts', async (_, shortcuts: unknown) => {
try {
await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), 'utf-8');
return { success: true };
} catch (error) {
console.error('Failed to save shortcuts:', error);
return { success: false, error: String(error) };
}
});
ipcMain.handle("save-shortcuts", async (_, shortcuts: unknown) => {
try {
await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), "utf-8");
return { success: true };
} catch (error) {
console.error("Failed to save shortcuts:", error);
return { success: false, error: String(error) };
}
});
}
+211 -218
View File
@@ -1,31 +1,29 @@
import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import fs from 'node:fs/promises'
import { createHudOverlayWindow, createEditorWindow, createSourceSelectorWindow } from './windows'
import { registerIpcHandlers } from './ipc/handlers'
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { app, BrowserWindow, Menu, nativeImage, Tray } from "electron";
import { registerIpcHandlers } from "./ipc/handlers";
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Use Screen & System Audio Recording permissions instead of CoreAudio Tap API on macOS.
// CoreAudio Tap requires NSAudioCaptureUsageDescription in the parent app's Info.plist,
// which doesn't work when running from a terminal/IDE during development, makes my life easier
if (process.platform === 'darwin') {
app.commandLine.appendSwitch('disable-features', 'MacCatapLoopbackAudioForScreenShare')
if (process.platform === "darwin") {
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
}
export const RECORDINGS_DIR = path.join(app.getPath('userData'), 'recordings')
export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
async function ensureRecordingsDir() {
try {
await fs.mkdir(RECORDINGS_DIR, { recursive: true })
console.log('RECORDINGS_DIR:', RECORDINGS_DIR)
console.log('User Data Path:', app.getPath('userData'))
} catch (error) {
console.error('Failed to create recordings directory:', error)
}
try {
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
console.log("RECORDINGS_DIR:", RECORDINGS_DIR);
console.log("User Data Path:", app.getPath("userData"));
} catch (error) {
console.error("Failed to create recordings directory:", error);
}
}
// The built directory structure
@@ -37,249 +35,244 @@ async function ensureRecordingsDir() {
// │ │ ├── main.js
// │ │ └── preload.mjs
// │
process.env.APP_ROOT = path.join(__dirname, '..')
process.env.APP_ROOT = path.join(__dirname, "..");
// Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x
export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron')
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist')
export const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
export const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
export const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
? path.join(process.env.APP_ROOT, "public")
: RENDERER_DIST;
// Window references
let mainWindow: BrowserWindow | null = null
let sourceSelectorWindow: BrowserWindow | null = null
let tray: Tray | null = null
let selectedSourceName = ''
let mainWindow: BrowserWindow | null = null;
let sourceSelectorWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let selectedSourceName = "";
// Tray Icons
const defaultTrayIcon = getTrayIcon('openscreen.png');
const recordingTrayIcon = getTrayIcon('rec-button.png');
const defaultTrayIcon = getTrayIcon("openscreen.png");
const recordingTrayIcon = getTrayIcon("rec-button.png");
function createWindow() {
mainWindow = createHudOverlayWindow()
mainWindow = createHudOverlayWindow();
}
function isEditorWindow(window: BrowserWindow) {
return window.webContents.getURL().includes('windowType=editor')
return window.webContents.getURL().includes("windowType=editor");
}
function sendEditorMenuAction(channel: 'menu-load-project' | 'menu-save-project' | 'menu-save-project-as') {
let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow
function sendEditorMenuAction(
channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as",
) {
let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow;
if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) {
createEditorWindowWrapper()
targetWindow = mainWindow
if (!targetWindow || targetWindow.isDestroyed()) return
if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) {
createEditorWindowWrapper();
targetWindow = mainWindow;
if (!targetWindow || targetWindow.isDestroyed()) return;
targetWindow.webContents.once('did-finish-load', () => {
if (!targetWindow || targetWindow.isDestroyed()) return
targetWindow.webContents.send(channel)
})
return
}
targetWindow.webContents.once("did-finish-load", () => {
if (!targetWindow || targetWindow.isDestroyed()) return;
targetWindow.webContents.send(channel);
});
return;
}
targetWindow.webContents.send(channel)
targetWindow.webContents.send(channel);
}
function setupApplicationMenu() {
const isMac = process.platform === 'darwin'
const template: Electron.MenuItemConstructorOptions[] = []
const isMac = process.platform === "darwin";
const template: Electron.MenuItemConstructorOptions[] = [];
if (isMac) {
template.push({
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
})
}
if (isMac) {
template.push({
label: app.name,
submenu: [
{ role: "about" },
{ type: "separator" },
{ role: "services" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
],
});
}
template.push(
{
label: 'File',
submenu: [
{
label: 'Load Project…',
accelerator: 'CmdOrCtrl+O',
click: () => sendEditorMenuAction('menu-load-project'),
},
{
label: 'Save Project…',
accelerator: 'CmdOrCtrl+S',
click: () => sendEditorMenuAction('menu-save-project'),
},
{
label: 'Save Project As…',
accelerator: 'CmdOrCtrl+Shift+S',
click: () => sendEditorMenuAction('menu-save-project-as'),
},
...(isMac ? [] : [{ type: 'separator' as const }, { role: 'quit' as const }]),
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Window',
submenu: isMac
? [
{ role: 'minimize' },
{ role: 'zoom' },
{ type: 'separator' },
{ role: 'front' },
]
: [
{ role: 'minimize' },
{ role: 'close' },
],
},
)
template.push(
{
label: "File",
submenu: [
{
label: "Load Project…",
accelerator: "CmdOrCtrl+O",
click: () => sendEditorMenuAction("menu-load-project"),
},
{
label: "Save Project…",
accelerator: "CmdOrCtrl+S",
click: () => sendEditorMenuAction("menu-save-project"),
},
{
label: "Save Project As…",
accelerator: "CmdOrCtrl+Shift+S",
click: () => sendEditorMenuAction("menu-save-project-as"),
},
...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]),
],
},
{
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "selectAll" },
],
},
{
label: "View",
submenu: [
{ role: "reload" },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{ role: "togglefullscreen" },
],
},
{
label: "Window",
submenu: isMac
? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }]
: [{ role: "minimize" }, { role: "close" }],
},
);
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
function createTray() {
tray = new Tray(defaultTrayIcon);
tray = new Tray(defaultTrayIcon);
}
function getTrayIcon(filename: string) {
return nativeImage.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename)).resize({
width: 24,
height: 24,
quality: 'best'
});
return nativeImage
.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename))
.resize({
width: 24,
height: 24,
quality: "best",
});
}
function updateTrayMenu(recording: boolean = false) {
if (!tray) return;
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
const menuTemplate = recording
? [
{
label: "Stop Recording",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("stop-recording-from-tray");
}
},
},
]
: [
{
label: "Open",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.isMinimized() && mainWindow.restore();
} else {
createWindow();
}
},
},
{
label: "Quit",
click: () => {
app.quit();
},
},
];
tray.setImage(trayIcon);
tray.setToolTip(trayToolTip);
tray.setContextMenu(Menu.buildFromTemplate(menuTemplate));
if (!tray) return;
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
const menuTemplate = recording
? [
{
label: "Stop Recording",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("stop-recording-from-tray");
}
},
},
]
: [
{
label: "Open",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.isMinimized() && mainWindow.restore();
} else {
createWindow();
}
},
},
{
label: "Quit",
click: () => {
app.quit();
},
},
];
tray.setImage(trayIcon);
tray.setToolTip(trayToolTip);
tray.setContextMenu(Menu.buildFromTemplate(menuTemplate));
}
function createEditorWindowWrapper() {
if (mainWindow) {
mainWindow.close()
mainWindow = null
}
mainWindow = createEditorWindow()
if (mainWindow) {
mainWindow.close();
mainWindow = null;
}
mainWindow = createEditorWindow();
}
function createSourceSelectorWindowWrapper() {
sourceSelectorWindow = createSourceSelectorWindow()
sourceSelectorWindow.on('closed', () => {
sourceSelectorWindow = null
})
return sourceSelectorWindow
sourceSelectorWindow = createSourceSelectorWindow();
sourceSelectorWindow.on("closed", () => {
sourceSelectorWindow = null;
});
return sourceSelectorWindow;
}
// On macOS, applications and their menu bar stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
// Keep app running (macOS behavior)
})
app.on('activate', () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
app.on("window-all-closed", () => {
// Keep app running (macOS behavior)
});
app.on("activate", () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// Register all IPC handlers when app is ready
app.whenReady().then(async () => {
// Listen for HUD overlay quit event (macOS only)
const { ipcMain } = await import('electron');
ipcMain.on('hud-overlay-close', () => {
app.quit();
});
createTray()
updateTrayMenu()
setupApplicationMenu()
// Ensure recordings directory exists
await ensureRecordingsDir()
// Listen for HUD overlay quit event (macOS only)
const { ipcMain } = await import("electron");
ipcMain.on("hud-overlay-close", () => {
app.quit();
});
createTray();
updateTrayMenu();
setupApplicationMenu();
// Ensure recordings directory exists
await ensureRecordingsDir();
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
() => mainWindow,
() => sourceSelectorWindow,
(recording: boolean, sourceName: string) => {
selectedSourceName = sourceName
if (!tray) createTray();
updateTrayMenu(recording);
if (!recording) {
if (mainWindow) mainWindow.restore();
}
}
)
createWindow()
})
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
() => mainWindow,
() => sourceSelectorWindow,
(recording: boolean, sourceName: string) => {
selectedSourceName = sourceName;
if (!tray) createTray();
updateTrayMenu(recording);
if (!recording) {
if (mainWindow) mainWindow.restore();
}
},
);
createWindow();
});
+102 -102
View File
@@ -1,105 +1,105 @@
import { contextBridge, ipcRenderer } from 'electron'
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld('electronAPI', {
hudOverlayHide: () => {
ipcRenderer.send('hud-overlay-hide');
},
hudOverlayClose: () => {
ipcRenderer.send('hud-overlay-close');
},
getAssetBasePath: async () => {
// ask main process for the correct base path (production vs dev)
return await ipcRenderer.invoke('get-asset-base-path')
},
getSources: async (opts: Electron.SourcesOptions) => {
return await ipcRenderer.invoke('get-sources', opts)
},
switchToEditor: () => {
return ipcRenderer.invoke('switch-to-editor')
},
openSourceSelector: () => {
return ipcRenderer.invoke('open-source-selector')
},
selectSource: (source: any) => {
return ipcRenderer.invoke('select-source', source)
},
getSelectedSource: () => {
return ipcRenderer.invoke('get-selected-source')
},
contextBridge.exposeInMainWorld("electronAPI", {
hudOverlayHide: () => {
ipcRenderer.send("hud-overlay-hide");
},
hudOverlayClose: () => {
ipcRenderer.send("hud-overlay-close");
},
getAssetBasePath: async () => {
// ask main process for the correct base path (production vs dev)
return await ipcRenderer.invoke("get-asset-base-path");
},
getSources: async (opts: Electron.SourcesOptions) => {
return await ipcRenderer.invoke("get-sources", opts);
},
switchToEditor: () => {
return ipcRenderer.invoke("switch-to-editor");
},
openSourceSelector: () => {
return ipcRenderer.invoke("open-source-selector");
},
selectSource: (source: any) => {
return ipcRenderer.invoke("select-source", source);
},
getSelectedSource: () => {
return ipcRenderer.invoke("get-selected-source");
},
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke('store-recorded-video', videoData, fileName)
},
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
},
getRecordedVideoPath: () => {
return ipcRenderer.invoke('get-recorded-video-path')
},
setRecordingState: (recording: boolean) => {
return ipcRenderer.invoke('set-recording-state', recording)
},
getCursorTelemetry: (videoPath?: string) => {
return ipcRenderer.invoke('get-cursor-telemetry', videoPath)
},
onStopRecordingFromTray: (callback: () => void) => {
const listener = () => callback()
ipcRenderer.on('stop-recording-from-tray', listener)
return () => ipcRenderer.removeListener('stop-recording-from-tray', listener)
},
openExternalUrl: (url: string) => {
return ipcRenderer.invoke('open-external-url', url)
},
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke('save-exported-video', videoData, fileName)
},
openVideoFilePicker: () => {
return ipcRenderer.invoke('open-video-file-picker')
},
setCurrentVideoPath: (path: string) => {
return ipcRenderer.invoke('set-current-video-path', path)
},
getCurrentVideoPath: () => {
return ipcRenderer.invoke('get-current-video-path')
},
clearCurrentVideoPath: () => {
return ipcRenderer.invoke('clear-current-video-path')
},
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
return ipcRenderer.invoke('save-project-file', projectData, suggestedName, existingProjectPath)
},
loadProjectFile: () => {
return ipcRenderer.invoke('load-project-file')
},
loadCurrentProjectFile: () => {
return ipcRenderer.invoke('load-current-project-file')
},
onMenuLoadProject: (callback: () => void) => {
const listener = () => callback()
ipcRenderer.on('menu-load-project', listener)
return () => ipcRenderer.removeListener('menu-load-project', listener)
},
onMenuSaveProject: (callback: () => void) => {
const listener = () => callback()
ipcRenderer.on('menu-save-project', listener)
return () => ipcRenderer.removeListener('menu-save-project', listener)
},
onMenuSaveProjectAs: (callback: () => void) => {
const listener = () => callback()
ipcRenderer.on('menu-save-project-as', listener)
return () => ipcRenderer.removeListener('menu-save-project-as', listener)
},
getPlatform: () => {
return ipcRenderer.invoke('get-platform')
},
revealInFolder: (filePath: string) => {
return ipcRenderer.invoke('reveal-in-folder', filePath)
},
getShortcuts: () => {
return ipcRenderer.invoke('get-shortcuts')
},
saveShortcuts: (shortcuts: unknown) => {
return ipcRenderer.invoke('save-shortcuts', shortcuts)
},
setMicrophoneExpanded: (expanded: boolean) => {
ipcRenderer.send('hud:setMicrophoneExpanded', expanded)
},
})
getRecordedVideoPath: () => {
return ipcRenderer.invoke("get-recorded-video-path");
},
setRecordingState: (recording: boolean) => {
return ipcRenderer.invoke("set-recording-state", recording);
},
getCursorTelemetry: (videoPath?: string) => {
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
},
onStopRecordingFromTray: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("stop-recording-from-tray", listener);
return () => ipcRenderer.removeListener("stop-recording-from-tray", listener);
},
openExternalUrl: (url: string) => {
return ipcRenderer.invoke("open-external-url", url);
},
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
return ipcRenderer.invoke("save-exported-video", videoData, fileName);
},
openVideoFilePicker: () => {
return ipcRenderer.invoke("open-video-file-picker");
},
setCurrentVideoPath: (path: string) => {
return ipcRenderer.invoke("set-current-video-path", path);
},
getCurrentVideoPath: () => {
return ipcRenderer.invoke("get-current-video-path");
},
clearCurrentVideoPath: () => {
return ipcRenderer.invoke("clear-current-video-path");
},
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath);
},
loadProjectFile: () => {
return ipcRenderer.invoke("load-project-file");
},
loadCurrentProjectFile: () => {
return ipcRenderer.invoke("load-current-project-file");
},
onMenuLoadProject: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("menu-load-project", listener);
return () => ipcRenderer.removeListener("menu-load-project", listener);
},
onMenuSaveProject: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("menu-save-project", listener);
return () => ipcRenderer.removeListener("menu-save-project", listener);
},
onMenuSaveProjectAs: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("menu-save-project-as", listener);
return () => ipcRenderer.removeListener("menu-save-project-as", listener);
},
getPlatform: () => {
return ipcRenderer.invoke("get-platform");
},
revealInFolder: (filePath: string) => {
return ipcRenderer.invoke("reveal-in-folder", filePath);
},
getShortcuts: () => {
return ipcRenderer.invoke("get-shortcuts");
},
saveShortcuts: (shortcuts: unknown) => {
return ipcRenderer.invoke("save-shortcuts", shortcuts);
},
setMicrophoneExpanded: (expanded: boolean) => {
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
},
});
+121 -125
View File
@@ -1,155 +1,151 @@
import { BrowserWindow, screen } from 'electron'
import { ipcMain } from 'electron'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import path from "node:path";
import { fileURLToPath } from "node:url";
import { BrowserWindow, ipcMain, screen } from "electron";
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const APP_ROOT = path.join(__dirname, '..')
const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
const RENDERER_DIST = path.join(APP_ROOT, 'dist')
const APP_ROOT = path.join(__dirname, "..");
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
const RENDERER_DIST = path.join(APP_ROOT, "dist");
let hudOverlayWindow: BrowserWindow | null = null;
ipcMain.on('hud-overlay-hide', () => {
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
hudOverlayWindow.minimize();
}
ipcMain.on("hud-overlay-hide", () => {
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
hudOverlayWindow.minimize();
}
});
export function createHudOverlayWindow(): BrowserWindow {
const primaryDisplay = screen.getPrimaryDisplay();
const { workArea } = primaryDisplay;
const primaryDisplay = screen.getPrimaryDisplay();
const { workArea } = primaryDisplay;
const windowWidth = 500;
const windowHeight = 155;
const windowWidth = 500;
const windowHeight = 155;
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
const win = new BrowserWindow({
width: windowWidth,
height: windowHeight,
minWidth: 500,
maxWidth: 500,
minHeight: 155,
maxHeight: 155,
x: x,
y: y,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
hasShadow: false,
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true,
backgroundThrottling: false,
},
});
const win = new BrowserWindow({
width: windowWidth,
height: windowHeight,
minWidth: 500,
maxWidth: 500,
minHeight: 155,
maxHeight: 155,
x: x,
y: y,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
hasShadow: false,
webPreferences: {
preload: path.join(__dirname, 'preload.mjs'),
nodeIntegration: false,
contextIsolation: true,
backgroundThrottling: false,
},
})
win.webContents.on("did-finish-load", () => {
win?.webContents.send("main-process-message", new Date().toLocaleString());
});
hudOverlayWindow = win;
win.webContents.on('did-finish-load', () => {
win?.webContents.send('main-process-message', (new Date).toLocaleString())
})
win.on("closed", () => {
if (hudOverlayWindow === win) {
hudOverlayWindow = null;
}
});
hudOverlayWindow = win;
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=hud-overlay");
} else {
win.loadFile(path.join(RENDERER_DIST, "index.html"), {
query: { windowType: "hud-overlay" },
});
}
win.on('closed', () => {
if (hudOverlayWindow === win) {
hudOverlayWindow = null;
}
});
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=hud-overlay')
} else {
win.loadFile(path.join(RENDERER_DIST, 'index.html'), {
query: { windowType: 'hud-overlay' }
})
}
return win
return win;
}
export function createEditorWindow(): BrowserWindow {
const isMac = process.platform === 'darwin';
const isMac = process.platform === "darwin";
const win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
...(isMac && {
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 12, y: 12 },
}),
transparent: false,
resizable: true,
alwaysOnTop: false,
skipTaskbar: false,
title: 'OpenScreen',
backgroundColor: '#000000',
webPreferences: {
preload: path.join(__dirname, 'preload.mjs'),
nodeIntegration: false,
contextIsolation: true,
webSecurity: false,
backgroundThrottling: false,
},
})
const win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
...(isMac && {
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 12, y: 12 },
}),
transparent: false,
resizable: true,
alwaysOnTop: false,
skipTaskbar: false,
title: "OpenScreen",
backgroundColor: "#000000",
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true,
webSecurity: false,
backgroundThrottling: false,
},
});
// Maximize the window by default
win.maximize();
// Maximize the window by default
win.maximize();
win.webContents.on('did-finish-load', () => {
win?.webContents.send('main-process-message', (new Date).toLocaleString())
})
win.webContents.on("did-finish-load", () => {
win?.webContents.send("main-process-message", new Date().toLocaleString());
});
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=editor')
} else {
win.loadFile(path.join(RENDERER_DIST, 'index.html'), {
query: { windowType: 'editor' }
})
}
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=editor");
} else {
win.loadFile(path.join(RENDERER_DIST, "index.html"), {
query: { windowType: "editor" },
});
}
return win
return win;
}
export function createSourceSelectorWindow(): BrowserWindow {
const { width, height } = screen.getPrimaryDisplay().workAreaSize
const win = new BrowserWindow({
width: 620,
height: 420,
minHeight: 350,
maxHeight: 500,
x: Math.round((width - 620) / 2),
y: Math.round((height - 420) / 2),
frame: false,
resizable: false,
alwaysOnTop: true,
transparent: true,
backgroundColor: '#00000000',
webPreferences: {
preload: path.join(__dirname, 'preload.mjs'),
nodeIntegration: false,
contextIsolation: true,
},
})
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=source-selector')
} else {
win.loadFile(path.join(RENDERER_DIST, 'index.html'), {
query: { windowType: 'source-selector' }
})
}
const win = new BrowserWindow({
width: 620,
height: 420,
minHeight: 350,
maxHeight: 500,
x: Math.round((width - 620) / 2),
y: Math.round((height - 420) / 2),
frame: false,
resizable: false,
alwaysOnTop: true,
transparent: true,
backgroundColor: "#00000000",
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true,
},
});
return win
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=source-selector");
} else {
win.loadFile(path.join(RENDERER_DIST, "index.html"), {
query: { windowType: "source-selector" },
});
}
return win;
}
+1 -1
View File
@@ -48,7 +48,7 @@
"web-demuxer": "^4.0.0"
},
"devDependencies": {
"@biomejs/biome": "2.3.13",
"@biomejs/biome": "^2.3.13",
"@types/node": "^25.0.3",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
+1 -1
View File
@@ -57,7 +57,7 @@
"web-demuxer": "^4.0.0"
},
"devDependencies": {
"@biomejs/biome": "2.3.13",
"@biomejs/biome": "^2.3.13",
"@types/node": "^25.0.3",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
+5 -5
View File
@@ -1,6 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+42 -42
View File
@@ -1,42 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+47 -47
View File
@@ -1,47 +1,47 @@
import { useEffect, useState } from "react";
import { LaunchWindow } from "./components/launch/LaunchWindow";
import { SourceSelector } from "./components/launch/SourceSelector";
import VideoEditor from "./components/video-editor/VideoEditor";
import { loadAllCustomFonts } from "./lib/customFonts";
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
export default function App() {
const [windowType, setWindowType] = useState('');
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const type = params.get('windowType') || '';
setWindowType(type);
if (type === 'hud-overlay' || type === 'source-selector') {
document.body.style.background = 'transparent';
document.documentElement.style.background = 'transparent';
document.getElementById('root')?.style.setProperty('background', 'transparent');
}
// Load custom fonts on app initialization
loadAllCustomFonts().catch((error) => {
console.error('Failed to load custom fonts:', error);
});
}, []);
switch (windowType) {
case 'hud-overlay':
return <LaunchWindow />;
case 'source-selector':
return <SourceSelector />;
case 'editor':
return (
<ShortcutsProvider>
<VideoEditor />
<ShortcutsConfigDialog />
</ShortcutsProvider>
);
default:
return (
<div className="w-full h-full bg-background text-foreground">
<h1>Openscreen</h1>
</div>
);
}
}
import { useEffect, useState } from "react";
import { LaunchWindow } from "./components/launch/LaunchWindow";
import { SourceSelector } from "./components/launch/SourceSelector";
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
import VideoEditor from "./components/video-editor/VideoEditor";
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
import { loadAllCustomFonts } from "./lib/customFonts";
export default function App() {
const [windowType, setWindowType] = useState("");
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const type = params.get("windowType") || "";
setWindowType(type);
if (type === "hud-overlay" || type === "source-selector") {
document.body.style.background = "transparent";
document.documentElement.style.background = "transparent";
document.getElementById("root")?.style.setProperty("background", "transparent");
}
// Load custom fonts on app initialization
loadAllCustomFonts().catch((error) => {
console.error("Failed to load custom fonts:", error);
});
}, []);
switch (windowType) {
case "hud-overlay":
return <LaunchWindow />;
case "source-selector":
return <SourceSelector />;
case "editor":
return (
<ShortcutsProvider>
<VideoEditor />
<ShortcutsConfigDialog />
</ShortcutsProvider>
);
default:
return (
<div className="w-full h-full bg-background text-foreground">
<h1>Openscreen</h1>
</div>
);
}
}
+73 -72
View File
@@ -1,132 +1,133 @@
.electronDrag {
-webkit-app-region: drag;
-webkit-app-region: drag;
}
.electronNoDrag {
-webkit-app-region: no-drag;
-webkit-app-region: no-drag;
}
.hudBar {
isolation: isolate;
box-shadow:
0 2px 16px rgba(0, 0, 0, 0.25),
0 0 40px rgba(100, 80, 200, 0.08);
isolation: isolate;
box-shadow:
0 2px 16px rgba(0, 0, 0, 0.25),
0 0 40px rgba(100, 80, 200, 0.08);
}
/* Sub-pill group container */
.hudGroup {
display: flex;
align-items: center;
gap: 2px;
background: rgba(255, 255, 255, 0.05);
border-radius: 9999px;
padding: 4px 8px;
transition: background 0.15s ease;
display: flex;
align-items: center;
gap: 2px;
background: rgba(255, 255, 255, 0.05);
border-radius: 9999px;
padding: 4px 8px;
transition: background 0.15s ease;
}
.hudGroup:hover {
background: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.08);
}
/* Icon button within groups */
.hudIconBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 9999px;
transition: all 0.15s ease;
cursor: pointer;
background: transparent;
border: none;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 9999px;
transition: all 0.15s ease;
cursor: pointer;
background: transparent;
border: none;
color: #fff;
}
.hudIconBtn:hover {
background: rgba(255, 255, 255, 0.1);
transform: scale(1.08);
background: rgba(255, 255, 255, 0.1);
transform: scale(1.08);
}
.hudIconBtn:active {
transform: scale(0.95);
transform: scale(0.95);
}
/* Active icon glow (green) for enabled audio toggles */
.hudIconActive {
filter: drop-shadow(0 0 4px rgba(74, 222, 128, 0.4));
filter: drop-shadow(0 0 4px rgba(74, 222, 128, 0.4));
}
/* Recording pulse animation on the record group */
@keyframes recordPulse {
0%, 100% {
box-shadow: 0 0 8px rgba(239, 68, 68, 0.15);
}
50% {
box-shadow: 0 0 16px rgba(239, 68, 68, 0.4);
}
0%,
100% {
box-shadow: 0 0 8px rgba(239, 68, 68, 0.15);
}
50% {
box-shadow: 0 0 16px rgba(239, 68, 68, 0.4);
}
}
.recordingPulse {
animation: recordPulse 1.5s ease-in-out infinite;
background: rgba(239, 68, 68, 0.1) !important;
animation: recordPulse 1.5s ease-in-out infinite;
background: rgba(239, 68, 68, 0.1) !important;
}
/* Mic panel above the bar */
.micPanel {
background: linear-gradient(135deg, rgba(28, 28, 36, 0.97) 0%, rgba(18, 18, 26, 0.96) 100%);
backdrop-filter: blur(16px) saturate(140%);
-webkit-backdrop-filter: blur(16px) saturate(140%);
border: 1px solid rgba(80, 80, 120, 0.25);
border-radius: 16px;
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.2),
0 0 30px rgba(100, 80, 200, 0.06);
animation: micPanelIn 0.15s ease-out;
background: linear-gradient(135deg, rgba(28, 28, 36, 0.97) 0%, rgba(18, 18, 26, 0.96) 100%);
backdrop-filter: blur(16px) saturate(140%);
-webkit-backdrop-filter: blur(16px) saturate(140%);
border: 1px solid rgba(80, 80, 120, 0.25);
border-radius: 16px;
box-shadow:
0 2px 12px rgba(0, 0, 0, 0.2),
0 0 30px rgba(100, 80, 200, 0.06);
animation: micPanelIn 0.15s ease-out;
}
@keyframes micPanelIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Window control buttons */
.windowBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 3px;
border-radius: 9999px;
transition: all 0.15s ease;
cursor: pointer;
background: transparent;
border: none;
opacity: 0.5;
display: flex;
align-items: center;
justify-content: center;
padding: 3px;
border-radius: 9999px;
transition: all 0.15s ease;
cursor: pointer;
background: transparent;
border: none;
opacity: 0.5;
}
.windowBtn:hover {
opacity: 0.9;
background: rgba(255, 255, 255, 0.08);
opacity: 0.9;
background: rgba(255, 255, 255, 0.08);
}
/* Folder button */
.folderButton {
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.folderText {
color: #cbd5e1;
transition: text-decoration 0.15s;
color: #cbd5e1;
transition: text-decoration 0.15s;
}
.folderButton:hover .folderText {
text-decoration: underline;
text-decoration: underline;
}
+229 -217
View File
@@ -1,244 +1,256 @@
import { useState, useEffect } from "react";
import styles from "./LaunchWindow.module.css";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
import { AudioLevelMeter } from "../ui/audio-level-meter";
import { useEffect, useState } from "react";
import { BsRecordCircle } from "react-icons/bs";
import { FaRegStopCircle } from "react-icons/fa";
import { MdMonitor, MdMic, MdMicOff, MdVolumeUp, MdVolumeOff } from "react-icons/md";
import { RxDragHandleDots2 } from "react-icons/rx";
import { FaFolderMinus } from "react-icons/fa6";
import { FiMinus, FiX } from "react-icons/fi";
import { MdMic, MdMicOff, MdMonitor, MdVolumeOff, MdVolumeUp } from "react-icons/md";
import { RxDragHandleDots2 } from "react-icons/rx";
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { AudioLevelMeter } from "../ui/audio-level-meter";
import styles from "./LaunchWindow.module.css";
export function LaunchWindow() {
const { recording, toggleRecording, microphoneEnabled, setMicrophoneEnabled, microphoneDeviceId, setMicrophoneDeviceId, systemAudioEnabled, setSystemAudioEnabled } = useScreenRecorder();
const [recordingStart, setRecordingStart] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
const {
recording,
toggleRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
setMicrophoneDeviceId,
systemAudioEnabled,
setSystemAudioEnabled,
} = useScreenRecorder();
const [recordingStart, setRecordingStart] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
const showMicControls = microphoneEnabled && !recording;
const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices(microphoneEnabled);
const { level } = useAudioLevelMeter({
enabled: showMicControls,
deviceId: microphoneDeviceId,
});
const showMicControls = microphoneEnabled && !recording;
const { devices, selectedDeviceId, setSelectedDeviceId } =
useMicrophoneDevices(microphoneEnabled);
const { level } = useAudioLevelMeter({
enabled: showMicControls,
deviceId: microphoneDeviceId,
});
useEffect(() => {
if (selectedDeviceId && selectedDeviceId !== 'default') {
setMicrophoneDeviceId(selectedDeviceId);
}
}, [selectedDeviceId, setMicrophoneDeviceId]);
useEffect(() => {
if (selectedDeviceId && selectedDeviceId !== "default") {
setMicrophoneDeviceId(selectedDeviceId);
}
}, [selectedDeviceId, setMicrophoneDeviceId]);
useEffect(() => {
let timer: NodeJS.Timeout | null = null;
if (recording) {
if (!recordingStart) setRecordingStart(Date.now());
timer = setInterval(() => {
if (recordingStart) {
setElapsed(Math.floor((Date.now() - recordingStart) / 1000));
}
}, 1000);
} else {
setRecordingStart(null);
setElapsed(0);
if (timer) clearInterval(timer);
}
return () => {
if (timer) clearInterval(timer);
};
}, [recording, recordingStart]);
useEffect(() => {
let timer: NodeJS.Timeout | null = null;
if (recording) {
if (!recordingStart) setRecordingStart(Date.now());
timer = setInterval(() => {
if (recordingStart) {
setElapsed(Math.floor((Date.now() - recordingStart) / 1000));
}
}, 1000);
} else {
setRecordingStart(null);
setElapsed(0);
if (timer) clearInterval(timer);
}
return () => {
if (timer) clearInterval(timer);
};
}, [recording, recordingStart]);
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = (seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
};
const [selectedSource, setSelectedSource] = useState("Screen");
const [hasSelectedSource, setHasSelectedSource] = useState(false);
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
.toString()
.padStart(2, "0");
const s = (seconds % 60).toString().padStart(2, "0");
return `${m}:${s}`;
};
const [selectedSource, setSelectedSource] = useState("Screen");
const [hasSelectedSource, setHasSelectedSource] = useState(false);
useEffect(() => {
const checkSelectedSource = async () => {
if (window.electronAPI) {
const source = await window.electronAPI.getSelectedSource();
if (source) {
setSelectedSource(source.name);
setHasSelectedSource(true);
} else {
setSelectedSource("Screen");
setHasSelectedSource(false);
}
}
};
useEffect(() => {
const checkSelectedSource = async () => {
if (window.electronAPI) {
const source = await window.electronAPI.getSelectedSource();
if (source) {
setSelectedSource(source.name);
setHasSelectedSource(true);
} else {
setSelectedSource("Screen");
setHasSelectedSource(false);
}
}
};
checkSelectedSource();
checkSelectedSource();
const interval = setInterval(checkSelectedSource, 500);
return () => clearInterval(interval);
}, []);
const interval = setInterval(checkSelectedSource, 500);
return () => clearInterval(interval);
}, []);
const openSourceSelector = () => {
if (window.electronAPI) {
window.electronAPI.openSourceSelector();
}
};
const openSourceSelector = () => {
if (window.electronAPI) {
window.electronAPI.openSourceSelector();
}
};
const openVideoFile = async () => {
const result = await window.electronAPI.openVideoFilePicker();
const openVideoFile = async () => {
const result = await window.electronAPI.openVideoFilePicker();
if (result.canceled) {
return;
}
if (result.canceled) {
return;
}
if (result.success && result.path) {
await window.electronAPI.setCurrentVideoPath(result.path);
await window.electronAPI.switchToEditor();
}
};
if (result.success && result.path) {
await window.electronAPI.setCurrentVideoPath(result.path);
await window.electronAPI.switchToEditor();
}
};
const sendHudOverlayHide = () => {
if (window.electronAPI && window.electronAPI.hudOverlayHide) {
window.electronAPI.hudOverlayHide();
}
};
const sendHudOverlayClose = () => {
if (window.electronAPI && window.electronAPI.hudOverlayClose) {
window.electronAPI.hudOverlayClose();
}
};
const sendHudOverlayHide = () => {
if (window.electronAPI && window.electronAPI.hudOverlayHide) {
window.electronAPI.hudOverlayHide();
}
};
const sendHudOverlayClose = () => {
if (window.electronAPI && window.electronAPI.hudOverlayClose) {
window.electronAPI.hudOverlayClose();
}
};
const toggleMicrophone = () => {
if (!recording) {
setMicrophoneEnabled(!microphoneEnabled);
}
};
const toggleMicrophone = () => {
if (!recording) {
setMicrophoneEnabled(!microphoneEnabled);
}
};
return (
<div className="w-full h-full flex items-end justify-center bg-transparent">
<div className={`flex flex-col items-center gap-2 mx-auto ${styles.electronDrag}`}>
{/* Mic controls panel */}
{showMicControls && (
<div className={`flex items-center gap-2 px-4 py-2 ${styles.micPanel} ${styles.electronNoDrag}`}>
<select
value={microphoneDeviceId || selectedDeviceId}
onChange={(e) => {
setSelectedDeviceId(e.target.value);
setMicrophoneDeviceId(e.target.value);
}}
className="flex-1 bg-white/10 text-white text-xs rounded-full px-3 py-1 border border-white/20 outline-none truncate"
style={{ maxWidth: '70%' }}
>
{devices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label}
</option>
))}
</select>
<AudioLevelMeter level={level} className="w-24 h-4" />
</div>
)}
return (
<div className="w-full h-full flex items-end justify-center bg-transparent">
<div className={`flex flex-col items-center gap-2 mx-auto ${styles.electronDrag}`}>
{/* Mic controls panel */}
{showMicControls && (
<div
className={`flex items-center gap-2 px-4 py-2 ${styles.micPanel} ${styles.electronNoDrag}`}
>
<select
value={microphoneDeviceId || selectedDeviceId}
onChange={(e) => {
setSelectedDeviceId(e.target.value);
setMicrophoneDeviceId(e.target.value);
}}
className="flex-1 bg-white/10 text-white text-xs rounded-full px-3 py-1 border border-white/20 outline-none truncate"
style={{ maxWidth: "70%" }}
>
{devices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label}
</option>
))}
</select>
<AudioLevelMeter level={level} className="w-24 h-4" />
</div>
)}
{/* Main pill bar */}
<div
className={`flex items-center gap-1.5 px-2 py-1.5 ${styles.hudBar}`}
style={{
borderRadius: 9999,
background: 'linear-gradient(135deg, rgba(28,28,36,0.97) 0%, rgba(18,18,26,0.96) 100%)',
backdropFilter: 'blur(16px) saturate(140%)',
WebkitBackdropFilter: 'blur(16px) saturate(140%)',
border: '1px solid rgba(80,80,120,0.25)',
}}
>
{/* Drag handle */}
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
<RxDragHandleDots2 size={16} className="text-white/30" />
</div>
{/* Main pill bar */}
<div
className={`flex items-center gap-1.5 px-2 py-1.5 ${styles.hudBar}`}
style={{
borderRadius: 9999,
background: "linear-gradient(135deg, rgba(28,28,36,0.97) 0%, rgba(18,18,26,0.96) 100%)",
backdropFilter: "blur(16px) saturate(140%)",
WebkitBackdropFilter: "blur(16px) saturate(140%)",
border: "1px solid rgba(80,80,120,0.25)",
}}
>
{/* Drag handle */}
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
<RxDragHandleDots2 size={16} className="text-white/30" />
</div>
{/* Source selector */}
<button
className={`${styles.hudGroup} ${styles.electronNoDrag}`}
onClick={openSourceSelector}
disabled={recording}
title={selectedSource}
>
<MdMonitor size={14} className="text-white/80" />
<span className="text-white/70 text-[11px] max-w-[72px] truncate">{selectedSource}</span>
</button>
{/* Source selector */}
<button
className={`${styles.hudGroup} ${styles.electronNoDrag}`}
onClick={openSourceSelector}
disabled={recording}
title={selectedSource}
>
<MdMonitor size={14} className="text-white/80" />
<span className="text-white/70 text-[11px] max-w-[72px] truncate">
{selectedSource}
</span>
</button>
{/* Audio controls group */}
<div className={`${styles.hudGroup} ${styles.electronNoDrag}`}>
<button
className={`${styles.hudIconBtn} ${systemAudioEnabled ? styles.hudIconActive : ''}`}
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
disabled={recording}
title={systemAudioEnabled ? "Disable system audio" : "Enable system audio"}
>
{systemAudioEnabled ? (
<MdVolumeUp size={15} className="text-green-400" />
) : (
<MdVolumeOff size={15} className="text-white/40" />
)}
</button>
<button
className={`${styles.hudIconBtn} ${microphoneEnabled ? styles.hudIconActive : ''}`}
onClick={toggleMicrophone}
disabled={recording}
title={microphoneEnabled ? "Disable microphone" : "Enable microphone"}
>
{microphoneEnabled ? (
<MdMic size={15} className="text-green-400" />
) : (
<MdMicOff size={15} className="text-white/40" />
)}
</button>
</div>
{/* Audio controls group */}
<div className={`${styles.hudGroup} ${styles.electronNoDrag}`}>
<button
className={`${styles.hudIconBtn} ${systemAudioEnabled ? styles.hudIconActive : ""}`}
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
disabled={recording}
title={systemAudioEnabled ? "Disable system audio" : "Enable system audio"}
>
{systemAudioEnabled ? (
<MdVolumeUp size={15} className="text-green-400" />
) : (
<MdVolumeOff size={15} className="text-white/40" />
)}
</button>
<button
className={`${styles.hudIconBtn} ${microphoneEnabled ? styles.hudIconActive : ""}`}
onClick={toggleMicrophone}
disabled={recording}
title={microphoneEnabled ? "Disable microphone" : "Enable microphone"}
>
{microphoneEnabled ? (
<MdMic size={15} className="text-green-400" />
) : (
<MdMicOff size={15} className="text-white/40" />
)}
</button>
</div>
{/* Record/Stop group */}
<button
className={`${styles.hudGroup} ${styles.electronNoDrag} ${recording ? styles.recordingPulse : ''}`}
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
disabled={!hasSelectedSource && !recording}
style={{ flex: '0 0 auto' }}
>
{recording ? (
<>
<FaRegStopCircle size={13} className="text-red-400" />
<span className="text-red-400 text-xs font-semibold tabular-nums">{formatTime(elapsed)}</span>
</>
) : (
<BsRecordCircle size={14} className={hasSelectedSource ? "text-white/80" : "text-white/30"} />
)}
</button>
{/* Record/Stop group */}
<button
className={`${styles.hudGroup} ${styles.electronNoDrag} ${recording ? styles.recordingPulse : ""}`}
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
disabled={!hasSelectedSource && !recording}
style={{ flex: "0 0 auto" }}
>
{recording ? (
<>
<FaRegStopCircle size={13} className="text-red-400" />
<span className="text-red-400 text-xs font-semibold tabular-nums">
{formatTime(elapsed)}
</span>
</>
) : (
<BsRecordCircle
size={14}
className={hasSelectedSource ? "text-white/80" : "text-white/30"}
/>
)}
</button>
{/* Open file */}
<button
className={`${styles.hudIconBtn} ${styles.electronNoDrag}`}
onClick={openVideoFile}
disabled={recording}
title="Open video file"
>
<FaFolderMinus size={14} className="text-white/60" />
</button>
{/* Open file */}
<button
className={`${styles.hudIconBtn} ${styles.electronNoDrag}`}
onClick={openVideoFile}
disabled={recording}
title="Open video file"
>
<FaFolderMinus size={14} className="text-white/60" />
</button>
{/* Window controls */}
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
<button
className={styles.windowBtn}
title="Hide HUD"
onClick={sendHudOverlayHide}
>
<FiMinus size={14} className="text-white" />
</button>
<button
className={styles.windowBtn}
title="Close App"
onClick={sendHudOverlayClose}
>
<FiX size={14} className="text-white" />
</button>
</div>
</div>
</div>
</div>
);
{/* Window controls */}
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
<button className={styles.windowBtn} title="Hide HUD" onClick={sendHudOverlayHide}>
<FiMinus size={14} className="text-white" />
</button>
<button className={styles.windowBtn} title="Close App" onClick={sendHudOverlayClose}>
<FiX size={14} className="text-white" />
</button>
</div>
</div>
</div>
</div>
);
}
+53 -47
View File
@@ -1,93 +1,99 @@
.glassContainer {
background: linear-gradient(135deg, rgba(28,28,34,0.92) 0%, rgba(18,18,22,0.88) 100%);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border-radius: 14px;
box-shadow: 0 4px 16px 0 rgba(0,0,0,0.32), 0 1px 3px 0 rgba(0,0,0,0.18) inset;
border: 1px solid rgba(60,60,80,0.18);
background: linear-gradient(135deg, rgba(28, 28, 34, 0.92) 0%, rgba(18, 18, 22, 0.88) 100%);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border-radius: 14px;
box-shadow:
0 4px 16px 0 rgba(0, 0, 0, 0.32),
0 1px 3px 0 rgba(0, 0, 0, 0.18) inset;
border: 1px solid rgba(60, 60, 80, 0.18);
}
.sourceCard {
border-radius: 12px;
background: linear-gradient(120deg, rgba(38,38,48,0.98) 0%, rgba(24,24,32,0.96) 100%);
border: 1px solid rgba(60,60,80,0.22);
box-shadow: 0 2px 8px 0 rgba(0,0,0,0.18);
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
cursor: pointer;
border-radius: 12px;
background: linear-gradient(120deg, rgba(38, 38, 48, 0.98) 0%, rgba(24, 24, 32, 0.96) 100%);
border: 1px solid rgba(60, 60, 80, 0.22);
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.18);
transition:
box-shadow 0.2s ease,
border-color 0.2s ease,
transform 0.2s ease;
cursor: pointer;
}
.sourceCard:hover {
border-color: rgba(120,120,160,0.35);
transform: translateY(-1px);
box-shadow: 0 4px 12px 0 rgba(0,0,0,0.25);
border-color: rgba(120, 120, 160, 0.35);
transform: translateY(-1px);
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.25);
}
.selected {
border: 2px solid #34B27B;
background: linear-gradient(120deg, rgba(52,178,123,0.08) 0%, rgba(38,38,48,0.98) 100%);
box-shadow: 0 0 12px rgba(52,178,123,0.15), 0 0 4px rgba(52,178,123,0.1);
border: 2px solid #34b27b;
background: linear-gradient(120deg, rgba(52, 178, 123, 0.08) 0%, rgba(38, 38, 48, 0.98) 100%);
box-shadow:
0 0 12px rgba(52, 178, 123, 0.15),
0 0 4px rgba(52, 178, 123, 0.1);
}
.selected:hover {
transform: translateY(0);
transform: translateY(0);
}
.icon {
width: 13px;
height: 13px;
color: #c7d2fe;
width: 13px;
height: 13px;
color: #c7d2fe;
}
.name {
font-size: 0.8rem;
color: #e4e4e7;
font-weight: 500;
letter-spacing: 0.01em;
font-size: 0.8rem;
color: #e4e4e7;
font-weight: 500;
letter-spacing: 0.01em;
}
.cardText {
color: #a1a1aa;
font-size: 0.75rem;
color: #a1a1aa;
font-size: 0.75rem;
}
/* Checkmark badge */
.checkBadge {
width: 18px;
height: 18px;
background: #34B27B;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 8px rgba(52,178,123,0.4);
width: 18px;
height: 18px;
background: #34b27b;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 8px rgba(52, 178, 123, 0.4);
}
/* scrollbar */
.sourceGridScroll {
scrollbar-width: thin;
scrollbar-color: rgba(52, 178, 123, 0.5) rgba(40, 40, 50, 0.6);
scrollbar-width: thin;
scrollbar-color: rgba(52, 178, 123, 0.5) rgba(40, 40, 50, 0.6);
}
.sourceGridScroll::-webkit-scrollbar {
width: 8px;
width: 8px;
}
.sourceGridScroll::-webkit-scrollbar-track {
background: rgba(30, 30, 38, 0.5);
border-radius: 4px;
margin: 4px 0;
background: rgba(30, 30, 38, 0.5);
border-radius: 4px;
margin: 4px 0;
}
.sourceGridScroll::-webkit-scrollbar-thumb {
background: rgba(80, 80, 100, 0.6);
border-radius: 4px;
background: rgba(80, 80, 100, 0.6);
border-radius: 4px;
}
.sourceGridScroll::-webkit-scrollbar-thumb:hover {
background: rgba(52, 178, 123, 0.6);
background: rgba(52, 178, 123, 0.6);
}
.sourceGridScroll::-webkit-scrollbar-thumb:active {
background: rgba(52, 178, 123, 0.8);
background: rgba(52, 178, 123, 0.8);
}
+143 -130
View File
@@ -1,145 +1,158 @@
import { useState, useEffect } from "react";
import { Button } from "../ui/button";
import { useEffect, useState } from "react";
import { MdCheck } from "react-icons/md";
import { Button } from "../ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import styles from "./SourceSelector.module.css";
interface DesktopSource {
id: string;
name: string;
thumbnail: string | null;
display_id: string;
appIcon: string | null;
id: string;
name: string;
thumbnail: string | null;
display_id: string;
appIcon: string | null;
}
export function SourceSelector() {
const [sources, setSources] = useState<DesktopSource[]>([]);
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
const [loading, setLoading] = useState(true);
const [sources, setSources] = useState<DesktopSource[]>([]);
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchSources() {
setLoading(true);
try {
const rawSources = await window.electronAPI.getSources({
types: ['screen', 'window'],
thumbnailSize: { width: 320, height: 180 },
fetchWindowIcons: true
});
setSources(
rawSources.map(source => ({
id: source.id,
name:
source.id.startsWith('window:') && source.name.includes('')
? source.name.split('')[1] || source.name
: source.name,
thumbnail: source.thumbnail,
display_id: source.display_id,
appIcon: source.appIcon
}))
);
} catch (error) {
console.error('Error loading sources:', error);
} finally {
setLoading(false);
}
}
fetchSources();
}, []);
useEffect(() => {
async function fetchSources() {
setLoading(true);
try {
const rawSources = await window.electronAPI.getSources({
types: ["screen", "window"],
thumbnailSize: { width: 320, height: 180 },
fetchWindowIcons: true,
});
setSources(
rawSources.map((source) => ({
id: source.id,
name:
source.id.startsWith("window:") && source.name.includes("")
? source.name.split("")[1] || source.name
: source.name,
thumbnail: source.thumbnail,
display_id: source.display_id,
appIcon: source.appIcon,
})),
);
} catch (error) {
console.error("Error loading sources:", error);
} finally {
setLoading(false);
}
}
fetchSources();
}, []);
const screenSources = sources.filter(s => s.id.startsWith('screen:'));
const windowSources = sources.filter(s => s.id.startsWith('window:'));
const screenSources = sources.filter((s) => s.id.startsWith("screen:"));
const windowSources = sources.filter((s) => s.id.startsWith("window:"));
const handleSourceSelect = (source: DesktopSource) => setSelectedSource(source);
const handleShare = async () => {
if (selectedSource) await window.electronAPI.selectSource(selectedSource);
};
const handleSourceSelect = (source: DesktopSource) => setSelectedSource(source);
const handleShare = async () => {
if (selectedSource) await window.electronAPI.selectSource(selectedSource);
};
if (loading) {
return (
<div className={`h-full flex items-center justify-center ${styles.glassContainer}`} style={{ minHeight: '100vh' }}>
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#34B27B] mx-auto mb-2" />
<p className="text-xs text-zinc-400">Loading sources...</p>
</div>
</div>
);
}
if (loading) {
return (
<div
className={`h-full flex items-center justify-center ${styles.glassContainer}`}
style={{ minHeight: "100vh" }}
>
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#34B27B] mx-auto mb-2" />
<p className="text-xs text-zinc-400">Loading sources...</p>
</div>
</div>
);
}
const renderSourceCard = (source: DesktopSource) => {
const isSelected = selectedSource?.id === source.id;
return (
<div
key={source.id}
className={`${styles.sourceCard} ${isSelected ? styles.selected : ''} p-2`}
onClick={() => handleSourceSelect(source)}
>
<div className="relative mb-1.5">
<img
src={source.thumbnail || ''}
alt={source.name}
className="w-full aspect-video object-cover rounded-lg"
/>
{isSelected && (
<div className="absolute -top-1.5 -right-1.5">
<div className={styles.checkBadge}>
<MdCheck size={12} className="text-white" />
</div>
</div>
)}
</div>
<div className="flex items-center gap-1.5">
{source.appIcon && (
<img
src={source.appIcon}
alt=""
className={`${styles.icon} flex-shrink-0`}
/>
)}
<div className={`${styles.name} truncate`}>{source.name}</div>
</div>
</div>
);
};
const renderSourceCard = (source: DesktopSource) => {
const isSelected = selectedSource?.id === source.id;
return (
<div
key={source.id}
className={`${styles.sourceCard} ${isSelected ? styles.selected : ""} p-2`}
onClick={() => handleSourceSelect(source)}
>
<div className="relative mb-1.5">
<img
src={source.thumbnail || ""}
alt={source.name}
className="w-full aspect-video object-cover rounded-lg"
/>
{isSelected && (
<div className="absolute -top-1.5 -right-1.5">
<div className={styles.checkBadge}>
<MdCheck size={12} className="text-white" />
</div>
</div>
)}
</div>
<div className="flex items-center gap-1.5">
{source.appIcon && (
<img src={source.appIcon} alt="" className={`${styles.icon} flex-shrink-0`} />
)}
<div className={`${styles.name} truncate`}>{source.name}</div>
</div>
</div>
);
};
return (
<div className={`min-h-screen flex flex-col ${styles.glassContainer}`}>
<div className="flex-1 flex flex-col w-full px-4 pt-4">
<Tabs defaultValue="screens" className="flex-1 flex flex-col">
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-full">
<TabsTrigger value="screens" className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all">Screens</TabsTrigger>
<TabsTrigger value="windows" className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all">Windows</TabsTrigger>
</TabsList>
<div className="flex-1 min-h-0">
<TabsContent value="screens" className="h-full mt-0">
<div className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}>
{screenSources.map(renderSourceCard)}
</div>
</TabsContent>
<TabsContent value="windows" className="h-full mt-0">
<div className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}>
{windowSources.map(renderSourceCard)}
</div>
</TabsContent>
</div>
</Tabs>
</div>
<div className="p-3 flex justify-center gap-2">
<Button
variant="ghost"
onClick={() => window.close()}
className="px-5 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/5 rounded-full"
>
Cancel
</Button>
<Button
onClick={handleShare}
disabled={!selectedSource}
className="px-5 py-1 text-xs bg-[#34B27B] text-white hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
>
Share
</Button>
</div>
</div>
);
return (
<div className={`min-h-screen flex flex-col ${styles.glassContainer}`}>
<div className="flex-1 flex flex-col w-full px-4 pt-4">
<Tabs defaultValue="screens" className="flex-1 flex flex-col">
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-full">
<TabsTrigger
value="screens"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
>
Screens
</TabsTrigger>
<TabsTrigger
value="windows"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
>
Windows
</TabsTrigger>
</TabsList>
<div className="flex-1 min-h-0">
<TabsContent value="screens" className="h-full mt-0">
<div
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
>
{screenSources.map(renderSourceCard)}
</div>
</TabsContent>
<TabsContent value="windows" className="h-full mt-0">
<div
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
>
{windowSources.map(renderSourceCard)}
</div>
</TabsContent>
</div>
</Tabs>
</div>
<div className="p-3 flex justify-center gap-2">
<Button
variant="ghost"
onClick={() => window.close()}
className="px-5 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/5 rounded-full"
>
Cancel
</Button>
<Button
onClick={handleShare}
disabled={!selectedSource}
className="px-5 py-1 text-xs bg-[#34B27B] text-white hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
>
Share
</Button>
</div>
</div>
);
}
+43 -43
View File
@@ -1,55 +1,55 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b border-white/5", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b border-white/5", className)}
{...props}
/>
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-3 text-sm font-medium text-slate-200 transition-all hover:text-white [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-slate-400 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-3 text-sm font-medium text-slate-200 transition-all hover:text-white [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-slate-400 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+37 -37
View File
@@ -1,37 +1,37 @@
interface AudioLevelMeterProps {
level: number; // 0-100
className?: string;
}
const bars = [
{ threshold: 10, height: '30%' },
{ threshold: 25, height: '45%' },
{ threshold: 45, height: '60%' },
{ threshold: 65, height: '75%' },
{ threshold: 85, height: '90%' },
];
function getBarColor(level: number, threshold: number) {
if (!level || level < threshold) return 'bg-slate-700';
if (threshold > 80) return 'bg-red-500';
if (threshold > 60) return 'bg-yellow-500';
if (threshold > 40) return 'bg-green-500';
return 'bg-emerald-500';
}
export function AudioLevelMeter({ level, className = "" }: AudioLevelMeterProps) {
return (
<div className={`flex items-end justify-between gap-1.5 h-6 ${className}`}>
{bars.map((bar, index) => (
<div
key={index}
className={`flex-1 rounded-sm transition-all duration-100 ease-out ${getBarColor(level, bar.threshold)}`}
style={{
height: level >= bar.threshold ? bar.height : '15%',
opacity: level >= bar.threshold ? 1 : 0.4,
}}
/>
))}
</div>
);
}
interface AudioLevelMeterProps {
level: number; // 0-100
className?: string;
}
const bars = [
{ threshold: 10, height: "30%" },
{ threshold: 25, height: "45%" },
{ threshold: 45, height: "60%" },
{ threshold: 65, height: "75%" },
{ threshold: 85, height: "90%" },
];
function getBarColor(level: number, threshold: number) {
if (!level || level < threshold) return "bg-slate-700";
if (threshold > 80) return "bg-red-500";
if (threshold > 60) return "bg-yellow-500";
if (threshold > 40) return "bg-green-500";
return "bg-emerald-500";
}
export function AudioLevelMeter({ level, className = "" }: AudioLevelMeterProps) {
return (
<div className={`flex items-end justify-between gap-1.5 h-6 ${className}`}>
{bars.map((bar, index) => (
<div
key={index}
className={`flex-1 rounded-sm transition-all duration-100 ease-out ${getBarColor(level, bar.threshold)}`}
style={{
height: level >= bar.threshold ? bar.height : "15%",
opacity: level >= bar.threshold ? 1 : 0.4,
}}
/>
))}
</div>
);
}
+41 -48
View File
@@ -1,57 +1,50 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants }
export { Button, buttonVariants };
+47 -68
View File
@@ -1,76 +1,55 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props}
/>
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
),
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+81 -86
View File
@@ -1,86 +1,81 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from "./popover"
interface ContentClampProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
truncateLength?: number
}
function ContentClamp({
children,
className,
truncateLength = 50,
...props
}: ContentClampProps) {
const text = typeof children === "string" ? children : String(children ?? "")
const isTruncated = text.length > truncateLength
const [open, setOpen] = React.useState(false)
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null)
const handleMouseEnter = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
setOpen(true)
}
const handleMouseLeave = () => {
timeoutRef.current = setTimeout(() => {
setOpen(false)
}, 100)
}
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
if (!isTruncated) {
return (
<div className={cn("inline", className)} {...props}>
{children}
</div>
)
}
const truncatedText = text.slice(0, truncateLength) + "..."
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<span
className={className}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={(e) => e.preventDefault()}
{...props}
>
{truncatedText}
</span>
</PopoverTrigger>
<PopoverContent
className="w-auto max-w-sm rounded-lg border border-white bg-popover p-3 text-sm text-popover-foreground"
sideOffset={8}
animated={false}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onPointerDownOutside={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
>
<PopoverArrow className="fill-white" />
{children}
</PopoverContent>
</Popover>
)
}
export { ContentClamp }
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from "./popover";
interface ContentClampProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
truncateLength?: number;
}
function ContentClamp({ children, className, truncateLength = 50, ...props }: ContentClampProps) {
const text = typeof children === "string" ? children : String(children ?? "");
const isTruncated = text.length > truncateLength;
const [open, setOpen] = React.useState(false);
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const handleMouseEnter = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setOpen(true);
};
const handleMouseLeave = () => {
timeoutRef.current = setTimeout(() => {
setOpen(false);
}, 100);
};
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
if (!isTruncated) {
return (
<div className={cn("inline", className)} {...props}>
{children}
</div>
);
}
const truncatedText = text.slice(0, truncateLength) + "...";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<span
className={className}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={(e) => e.preventDefault()}
{...props}
>
{truncatedText}
</span>
</PopoverTrigger>
<PopoverContent
className="w-auto max-w-sm rounded-lg border border-white bg-popover p-3 text-sm text-popover-foreground"
sideOffset={8}
animated={false}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onPointerDownOutside={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
>
<PopoverArrow className="fill-white" />
{children}
</PopoverContent>
</Popover>
);
}
export { ContentClamp };
+81 -99
View File
@@ -1,120 +1,102 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-[9999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-[9999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-[10000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-[10000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+152 -165
View File
@@ -1,199 +1,186 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+23 -24
View File
@@ -1,24 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
+1 -1
View File
@@ -15,4 +15,4 @@ function ItemContent({ children, classes }: ItemContentProps) {
);
}
export default ItemContent;
export default ItemContent;
+20 -23
View File
@@ -1,23 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface LabelProps
extends React.LabelHTMLAttributes<HTMLLabelElement> {}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
({ className, ...props }, ref) => {
return (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
)
}
)
Label.displayName = "Label"
export { Label }
import * as React from "react";
import { cn } from "@/lib/utils";
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(({ className, ...props }, ref) => {
return (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
);
});
Label.displayName = "Label";
export { Label };
+42 -48
View File
@@ -1,66 +1,60 @@
"use client"
"use client";
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
animated = true,
...props
className,
align = "center",
sideOffset = 4,
animated = true,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
animated?: boolean
animated?: boolean;
}) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
animated &&
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin)",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
animated &&
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin)",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
function PopoverArrow({
className,
...props
className,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Arrow>) {
return (
<PopoverPrimitive.Arrow
data-slot="popover-arrow"
className={cn("fill-popover", className)}
{...props}
/>
)
return (
<PopoverPrimitive.Arrow
data-slot="popover-arrow"
className={cn("fill-popover", className)}
{...props}
/>
);
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow };
+124 -131
View File
@@ -1,160 +1,153 @@
"use client"
"use client";
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+18 -21
View File
@@ -1,26 +1,23 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import * as SliderPrimitive from "@radix-ui/react-slider";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-white/10">
<SliderPrimitive.Range className="absolute h-full bg-[#34B27B]" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border-2 border-[#34B27B] bg-[#34B27B] shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B]/50 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-white/10">
<SliderPrimitive.Range className="absolute h-full bg-[#34B27B]" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border-2 border-[#34B27B] bg-[#34B27B] shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B]/50 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider }
export { Slider };
+17 -19
View File
@@ -3,25 +3,23 @@ import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="light"
className="toaster group"
duration={3000}
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
return (
<Sonner
theme="light"
className="toaster group"
duration={3000}
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };
+25 -25
View File
@@ -1,30 +1,30 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
"data-[state=checked]:bg-[#34B27B] data-[state=unchecked]:bg-[#23232a]",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform",
"bg-[#f5f5f7] dark:bg-[#23232a]",
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
"data-[state=checked]:bg-[#34B27B] data-[state=unchecked]:bg-[#23232a]",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform",
"bg-[#f5f5f7] dark:bg-[#23232a]",
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch }
export { Switch };
+41 -41
View File
@@ -1,53 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsList, TabsTrigger, TabsContent };
+44 -49
View File
@@ -1,61 +1,56 @@
"use client"
"use client";
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import * as React from "react";
import { toggleVariants } from "@/components/ui/toggle";
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
size: "default",
variant: "default",
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem }
export { ToggleGroup, ToggleGroupItem };
+34 -36
View File
@@ -1,45 +1,43 @@
"use client"
"use client";
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
));
Toggle.displayName = TogglePrimitive.Root.displayName
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants }
export { Toggle, toggleVariants };
@@ -1,181 +1,181 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Plus } from 'lucide-react';
import { toast } from 'sonner';
import {
addCustomFont,
generateFontId,
parseFontFamilyFromImport,
isValidGoogleFontsUrl,
type CustomFont,
} from '@/lib/customFonts';
interface AddCustomFontDialogProps {
onFontAdded?: (font: CustomFont) => void;
}
export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
const [open, setOpen] = useState(false);
const [importUrl, setImportUrl] = useState('');
const [fontName, setFontName] = useState('');
const [loading, setLoading] = useState(false);
const handleImportUrlChange = (url: string) => {
setImportUrl(url);
// Auto-extract font name if valid Google Fonts URL
if (isValidGoogleFontsUrl(url)) {
const extracted = parseFontFamilyFromImport(url);
if (extracted && !fontName) {
setFontName(extracted);
}
}
};
const handleAdd = async () => {
// Validate inputs
if (!importUrl.trim()) {
toast.error('Please enter a Google Fonts import URL');
return;
}
if (!isValidGoogleFontsUrl(importUrl)) {
toast.error('Please enter a valid Google Fonts URL');
return;
}
if (!fontName.trim()) {
toast.error('Please enter a font name');
return;
}
setLoading(true);
try {
// Extract font family from URL
const fontFamily = parseFontFamilyFromImport(importUrl);
if (!fontFamily) {
toast.error('Could not extract font family from URL');
setLoading(false);
return;
}
// Create custom font object
const newFont: CustomFont = {
id: generateFontId(fontName),
name: fontName.trim(),
fontFamily: fontFamily,
importUrl: importUrl.trim(),
};
// Add font (this will load and verify it) - throws if it fails
await addCustomFont(newFont);
// Notify parent
if (onFontAdded) {
onFontAdded(newFont);
}
toast.success(`Font "${fontName}" added successfully`);
// Reset and close
setImportUrl('');
setFontName('');
setOpen(false);
} catch (error) {
console.error('Failed to add custom font:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to load font';
toast.error('Failed to add font', {
description: errorMessage.includes('timeout')
? 'Font took too long to load. Please check the URL and try again.'
: 'The font could not be loaded. Please verify the Google Fonts URL is correct.',
});
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10 h-9 text-xs"
>
<Plus className="w-3 h-3 mr-1" />
Add Google Font
</Button>
</DialogTrigger>
<DialogContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
<DialogHeader>
<DialogTitle>Add Google Font</DialogTitle>
<DialogDescription className="text-slate-400">
Add a custom font from Google Fonts to use in your annotations.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="import-url" className="text-slate-200">
Google Fonts Import URL
</Label>
<Input
id="import-url"
placeholder="https://fonts.googleapis.com/css2?family=Roboto&display=swap"
value={importUrl}
onChange={(e) => handleImportUrlChange(e.target.value)}
className="bg-white/5 border-white/10 text-slate-200"
/>
<p className="text-xs text-slate-400">
Get this from Google Fonts: Select a font Click "Get font" Copy the @import URL
</p>
</div>
<div className="space-y-2">
<Label htmlFor="font-name" className="text-slate-200">
Display Name
</Label>
<Input
id="font-name"
placeholder="My Custom Font"
value={fontName}
onChange={(e) => setFontName(e.target.value)}
className="bg-white/5 border-white/10 text-slate-200"
/>
<p className="text-xs text-slate-400">
This is how the font will appear in the font selector
</p>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button
variant="outline"
onClick={() => setOpen(false)}
className="bg-white/5 border-white/10 text-slate-200 hover:bg-white/10"
>
Cancel
</Button>
<Button
onClick={handleAdd}
disabled={loading}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{loading ? 'Adding...' : 'Add Font'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
import { Plus } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
addCustomFont,
type CustomFont,
generateFontId,
isValidGoogleFontsUrl,
parseFontFamilyFromImport,
} from "@/lib/customFonts";
interface AddCustomFontDialogProps {
onFontAdded?: (font: CustomFont) => void;
}
export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
const [open, setOpen] = useState(false);
const [importUrl, setImportUrl] = useState("");
const [fontName, setFontName] = useState("");
const [loading, setLoading] = useState(false);
const handleImportUrlChange = (url: string) => {
setImportUrl(url);
// Auto-extract font name if valid Google Fonts URL
if (isValidGoogleFontsUrl(url)) {
const extracted = parseFontFamilyFromImport(url);
if (extracted && !fontName) {
setFontName(extracted);
}
}
};
const handleAdd = async () => {
// Validate inputs
if (!importUrl.trim()) {
toast.error("Please enter a Google Fonts import URL");
return;
}
if (!isValidGoogleFontsUrl(importUrl)) {
toast.error("Please enter a valid Google Fonts URL");
return;
}
if (!fontName.trim()) {
toast.error("Please enter a font name");
return;
}
setLoading(true);
try {
// Extract font family from URL
const fontFamily = parseFontFamilyFromImport(importUrl);
if (!fontFamily) {
toast.error("Could not extract font family from URL");
setLoading(false);
return;
}
// Create custom font object
const newFont: CustomFont = {
id: generateFontId(fontName),
name: fontName.trim(),
fontFamily: fontFamily,
importUrl: importUrl.trim(),
};
// Add font (this will load and verify it) - throws if it fails
await addCustomFont(newFont);
// Notify parent
if (onFontAdded) {
onFontAdded(newFont);
}
toast.success(`Font "${fontName}" added successfully`);
// Reset and close
setImportUrl("");
setFontName("");
setOpen(false);
} catch (error) {
console.error("Failed to add custom font:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to load font";
toast.error("Failed to add font", {
description: errorMessage.includes("timeout")
? "Font took too long to load. Please check the URL and try again."
: "The font could not be loaded. Please verify the Google Fonts URL is correct.",
});
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10 h-9 text-xs"
>
<Plus className="w-3 h-3 mr-1" />
Add Google Font
</Button>
</DialogTrigger>
<DialogContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
<DialogHeader>
<DialogTitle>Add Google Font</DialogTitle>
<DialogDescription className="text-slate-400">
Add a custom font from Google Fonts to use in your annotations.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="import-url" className="text-slate-200">
Google Fonts Import URL
</Label>
<Input
id="import-url"
placeholder="https://fonts.googleapis.com/css2?family=Roboto&display=swap"
value={importUrl}
onChange={(e) => handleImportUrlChange(e.target.value)}
className="bg-white/5 border-white/10 text-slate-200"
/>
<p className="text-xs text-slate-400">
Get this from Google Fonts: Select a font Click "Get font" Copy the @import URL
</p>
</div>
<div className="space-y-2">
<Label htmlFor="font-name" className="text-slate-200">
Display Name
</Label>
<Input
id="font-name"
placeholder="My Custom Font"
value={fontName}
onChange={(e) => setFontName(e.target.value)}
className="bg-white/5 border-white/10 text-slate-200"
/>
<p className="text-xs text-slate-400">
This is how the font will appear in the font selector
</p>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button
variant="outline"
onClick={() => setOpen(false)}
className="bg-white/5 border-white/10 text-slate-200 hover:bg-white/10"
>
Cancel
</Button>
<Button
onClick={handleAdd}
disabled={loading}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{loading ? "Adding..." : "Add Font"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+200 -198
View File
@@ -1,218 +1,220 @@
import { useRef } from "react";
import { Rnd } from "react-rnd";
import type { AnnotationRegion } from "./types";
import { cn } from "@/lib/utils";
import { getArrowComponent } from "./ArrowSvgs";
import type { AnnotationRegion } from "./types";
interface AnnotationOverlayProps {
annotation: AnnotationRegion;
isSelected: boolean;
containerWidth: number;
containerHeight: number;
onPositionChange: (id: string, position: { x: number; y: number }) => void;
onSizeChange: (id: string, size: { width: number; height: number }) => void;
onClick: (id: string) => void;
zIndex: number;
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
annotation: AnnotationRegion;
isSelected: boolean;
containerWidth: number;
containerHeight: number;
onPositionChange: (id: string, position: { x: number; y: number }) => void;
onSizeChange: (id: string, size: { width: number; height: number }) => void;
onClick: (id: string) => void;
zIndex: number;
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
}
export function AnnotationOverlay({
annotation,
isSelected,
containerWidth,
containerHeight,
onPositionChange,
onSizeChange,
onClick,
zIndex,
isSelectedBoost,
annotation,
isSelected,
containerWidth,
containerHeight,
onPositionChange,
onSizeChange,
onClick,
zIndex,
isSelectedBoost,
}: AnnotationOverlayProps) {
const x = (annotation.position.x / 100) * containerWidth;
const y = (annotation.position.y / 100) * containerHeight;
const width = (annotation.size.width / 100) * containerWidth;
const height = (annotation.size.height / 100) * containerHeight;
const x = (annotation.position.x / 100) * containerWidth;
const y = (annotation.position.y / 100) * containerHeight;
const width = (annotation.size.width / 100) * containerWidth;
const height = (annotation.size.height / 100) * containerHeight;
const isDraggingRef = useRef(false);
const isDraggingRef = useRef(false);
const renderArrow = () => {
const direction = annotation.figureData?.arrowDirection || 'right';
const color = annotation.figureData?.color || '#34B27B';
const strokeWidth = annotation.figureData?.strokeWidth || 4;
const renderArrow = () => {
const direction = annotation.figureData?.arrowDirection || "right";
const color = annotation.figureData?.color || "#34B27B";
const strokeWidth = annotation.figureData?.strokeWidth || 4;
const ArrowComponent = getArrowComponent(direction);
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
};
const ArrowComponent = getArrowComponent(direction);
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
};
const renderContent = () => {
switch (annotation.type) {
case 'text':
return (
<div
className="w-full h-full flex items-center p-2 overflow-hidden"
style={{
justifyContent: annotation.style.textAlign === 'left' ? 'flex-start' :
annotation.style.textAlign === 'right' ? 'flex-end' : 'center',
alignItems: 'center',
}}
>
<span
style={{
color: annotation.style.color,
backgroundColor: annotation.style.backgroundColor,
fontSize: `${annotation.style.fontSize}px`,
fontFamily: annotation.style.fontFamily,
fontWeight: annotation.style.fontWeight,
fontStyle: annotation.style.fontStyle,
textDecoration: annotation.style.textDecoration,
textAlign: annotation.style.textAlign,
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
boxDecorationBreak: 'clone',
WebkitBoxDecorationBreak: 'clone',
padding: '0.1em 0.2em',
borderRadius: '4px',
lineHeight: '1.4',
}}
>
{annotation.content}
</span>
</div>
);
const renderContent = () => {
switch (annotation.type) {
case "text":
return (
<div
className="w-full h-full flex items-center p-2 overflow-hidden"
style={{
justifyContent:
annotation.style.textAlign === "left"
? "flex-start"
: annotation.style.textAlign === "right"
? "flex-end"
: "center",
alignItems: "center",
}}
>
<span
style={{
color: annotation.style.color,
backgroundColor: annotation.style.backgroundColor,
fontSize: `${annotation.style.fontSize}px`,
fontFamily: annotation.style.fontFamily,
fontWeight: annotation.style.fontWeight,
fontStyle: annotation.style.fontStyle,
textDecoration: annotation.style.textDecoration,
textAlign: annotation.style.textAlign,
wordBreak: "break-word",
whiteSpace: "pre-wrap",
boxDecorationBreak: "clone",
WebkitBoxDecorationBreak: "clone",
padding: "0.1em 0.2em",
borderRadius: "4px",
lineHeight: "1.4",
}}
>
{annotation.content}
</span>
</div>
);
case 'image':
if (annotation.content && annotation.content.startsWith('data:image')) {
return (
<img
src={annotation.content}
alt="Annotation"
className="w-full h-full object-contain"
draggable={false}
/>
);
}
return (
<div className="w-full h-full flex items-center justify-center text-slate-400 text-sm">
No image
</div>
);
case "image":
if (annotation.content && annotation.content.startsWith("data:image")) {
return (
<img
src={annotation.content}
alt="Annotation"
className="w-full h-full object-contain"
draggable={false}
/>
);
}
return (
<div className="w-full h-full flex items-center justify-center text-slate-400 text-sm">
No image
</div>
);
case 'figure':
if (!annotation.figureData) {
return (
<div className="w-full h-full flex items-center justify-center text-slate-400 text-sm">
No arrow data
</div>
);
}
case "figure":
if (!annotation.figureData) {
return (
<div className="w-full h-full flex items-center justify-center text-slate-400 text-sm">
No arrow data
</div>
);
}
return (
<div className="w-full h-full flex items-center justify-center p-2">
{renderArrow()}
</div>
);
return (
<div className="w-full h-full flex items-center justify-center p-2">{renderArrow()}</div>
);
default:
return null;
}
};
default:
return null;
}
};
return (
<Rnd
position={{ x, y }}
size={{ width, height }}
onDragStart={() => {
isDraggingRef.current = true;
}}
onDragStop={(_e, d) => {
const xPercent = (d.x / containerWidth) * 100;
const yPercent = (d.y / containerHeight) * 100;
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
// Reset dragging flag after a short delay to prevent click event
setTimeout(() => {
isDraggingRef.current = false;
}, 100);
}}
onResizeStop={(_e, _direction, ref, _delta, position) => {
const xPercent = (position.x / containerWidth) * 100;
const yPercent = (position.y / containerHeight) * 100;
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
const heightPercent = (ref.offsetHeight / containerHeight) * 100;
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
onSizeChange(annotation.id, { width: widthPercent, height: heightPercent });
}}
onClick={() => {
if (isDraggingRef.current) return;
onClick(annotation.id);
}}
bounds="parent"
className={cn(
"cursor-move transition-all",
isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent"
)}
style={{
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
pointerEvents: isSelected ? 'auto' : 'none',
border: isSelected ? '2px solid rgba(52, 178, 123, 0.8)' : 'none',
backgroundColor: isSelected ? 'rgba(52, 178, 123, 0.1)' : 'transparent',
boxShadow: isSelected ? '0 0 0 1px rgba(52, 178, 123, 0.35)' : 'none',
}}
enableResizing={isSelected}
disableDragging={!isSelected}
resizeHandleStyles={{
topLeft: {
width: '12px',
height: '12px',
backgroundColor: isSelected ? 'white' : 'transparent',
border: isSelected ? '2px solid #34B27B' : 'none',
borderRadius: '50%',
left: '-6px',
top: '-6px',
cursor: 'nwse-resize',
},
topRight: {
width: '12px',
height: '12px',
backgroundColor: isSelected ? 'white' : 'transparent',
border: isSelected ? '2px solid #34B27B' : 'none',
borderRadius: '50%',
right: '-6px',
top: '-6px',
cursor: 'nesw-resize',
},
bottomLeft: {
width: '12px',
height: '12px',
backgroundColor: isSelected ? 'white' : 'transparent',
border: isSelected ? '2px solid #34B27B' : 'none',
borderRadius: '50%',
left: '-6px',
bottom: '-6px',
cursor: 'nesw-resize',
},
bottomRight: {
width: '12px',
height: '12px',
backgroundColor: isSelected ? 'white' : 'transparent',
border: isSelected ? '2px solid #34B27B' : 'none',
borderRadius: '50%',
right: '-6px',
bottom: '-6px',
cursor: 'nwse-resize',
},
}}
>
<div
className={cn(
"w-full h-full rounded-lg",
annotation.type === 'text' && "bg-transparent",
annotation.type === 'image' && "bg-transparent",
annotation.type === 'figure' && "bg-transparent",
isSelected && "shadow-lg"
)}
>
{renderContent()}
</div>
</Rnd>
);
return (
<Rnd
position={{ x, y }}
size={{ width, height }}
onDragStart={() => {
isDraggingRef.current = true;
}}
onDragStop={(_e, d) => {
const xPercent = (d.x / containerWidth) * 100;
const yPercent = (d.y / containerHeight) * 100;
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
// Reset dragging flag after a short delay to prevent click event
setTimeout(() => {
isDraggingRef.current = false;
}, 100);
}}
onResizeStop={(_e, _direction, ref, _delta, position) => {
const xPercent = (position.x / containerWidth) * 100;
const yPercent = (position.y / containerHeight) * 100;
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
const heightPercent = (ref.offsetHeight / containerHeight) * 100;
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
onSizeChange(annotation.id, { width: widthPercent, height: heightPercent });
}}
onClick={() => {
if (isDraggingRef.current) return;
onClick(annotation.id);
}}
bounds="parent"
className={cn(
"cursor-move transition-all",
isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
)}
style={{
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
pointerEvents: isSelected ? "auto" : "none",
border: isSelected ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
backgroundColor: isSelected ? "rgba(52, 178, 123, 0.1)" : "transparent",
boxShadow: isSelected ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
}}
enableResizing={isSelected}
disableDragging={!isSelected}
resizeHandleStyles={{
topLeft: {
width: "12px",
height: "12px",
backgroundColor: isSelected ? "white" : "transparent",
border: isSelected ? "2px solid #34B27B" : "none",
borderRadius: "50%",
left: "-6px",
top: "-6px",
cursor: "nwse-resize",
},
topRight: {
width: "12px",
height: "12px",
backgroundColor: isSelected ? "white" : "transparent",
border: isSelected ? "2px solid #34B27B" : "none",
borderRadius: "50%",
right: "-6px",
top: "-6px",
cursor: "nesw-resize",
},
bottomLeft: {
width: "12px",
height: "12px",
backgroundColor: isSelected ? "white" : "transparent",
border: isSelected ? "2px solid #34B27B" : "none",
borderRadius: "50%",
left: "-6px",
bottom: "-6px",
cursor: "nesw-resize",
},
bottomRight: {
width: "12px",
height: "12px",
backgroundColor: isSelected ? "white" : "transparent",
border: isSelected ? "2px solid #34B27B" : "none",
borderRadius: "50%",
right: "-6px",
bottom: "-6px",
cursor: "nwse-resize",
},
}}
>
<div
className={cn(
"w-full h-full rounded-lg",
annotation.type === "text" && "bg-transparent",
annotation.type === "image" && "bg-transparent",
annotation.type === "figure" && "bg-transparent",
isSelected && "shadow-lg",
)}
>
{renderContent()}
</div>
</Rnd>
);
}
File diff suppressed because it is too large Load Diff
+166 -158
View File
@@ -1,9 +1,9 @@
import type { ArrowDirection } from './types';
import type { ArrowDirection } from "./types";
interface ArrowSvgProps {
color: string;
strokeWidth: number;
className?: string;
color: string;
strokeWidth: number;
className?: string;
}
/**
@@ -13,182 +13,190 @@ interface ArrowSvgProps {
*/
export function ArrowUp({ color, strokeWidth, className }: ArrowSvgProps) {
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 50 20 L 50 80 M 50 20 L 35 35 M 50 20 L 65 35"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 50 20 L 50 80 M 50 20 L 35 35 M 50 20 L 65 35"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
}
export function ArrowDown({ color, strokeWidth, className }: ArrowSvgProps) {
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 50 20 L 50 80 M 50 80 L 35 65 M 50 80 L 65 65"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 50 20 L 50 80 M 50 80 L 35 65 M 50 80 L 65 65"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
}
export function ArrowLeft({ color, strokeWidth, className }: ArrowSvgProps) {
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 80 50 L 20 50 M 20 50 L 35 35 M 20 50 L 35 65"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 80 50 L 20 50 M 20 50 L 35 35 M 20 50 L 35 65"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
}
export function ArrowRight({ color, strokeWidth, className }: ArrowSvgProps) {
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 20 50 L 80 50 M 80 50 L 65 35 M 80 50 L 65 65"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 20 50 L 80 50 M 80 50 L 65 35 M 80 50 L 65 65"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
}
export function ArrowUpRight({ color, strokeWidth, className }: ArrowSvgProps) {
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 25 75 L 75 25 M 75 25 L 60 30 M 75 25 L 70 40"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 25 75 L 75 25 M 75 25 L 60 30 M 75 25 L 70 40"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
}
export function ArrowUpLeft({ color, strokeWidth, className }: ArrowSvgProps) {
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 75 75 L 25 25 M 25 25 L 40 30 M 25 25 L 30 40"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 75 75 L 25 25 M 25 25 L 40 30 M 25 25 L 30 40"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
}
export function ArrowDownRight({ color, strokeWidth, className }: ArrowSvgProps) {
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 25 25 L 75 75 M 75 75 L 70 60 M 75 75 L 60 70"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 25 25 L 75 75 M 75 75 L 70 60 M 75 75 L 60 70"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
}
export function ArrowDownLeft({ color, strokeWidth, className }: ArrowSvgProps) {
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: '100%', height: '100%' }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 75 25 L 25 75 M 25 75 L 30 60 M 25 75 L 40 70"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
return (
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
<defs>
<filter id="arrow-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
</filter>
</defs>
<path
d="M 75 25 L 25 75 M 25 75 L 30 60 M 25 75 L 40 70"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
filter="url(#arrow-shadow)"
/>
</svg>
);
}
export function getArrowComponent(direction: ArrowDirection) {
switch (direction) {
case 'up': return ArrowUp;
case 'down': return ArrowDown;
case 'left': return ArrowLeft;
case 'right': return ArrowRight;
case 'up-right': return ArrowUpRight;
case 'up-left': return ArrowUpLeft;
case 'down-right': return ArrowDownRight;
case 'down-left': return ArrowDownLeft;
}
switch (direction) {
case "up":
return ArrowUp;
case "down":
return ArrowDown;
case "left":
return ArrowLeft;
case "right":
return ArrowRight;
case "up-right":
return ArrowUpRight;
case "up-left":
return ArrowUpLeft;
case "down-right":
return ArrowDownRight;
case "down-left":
return ArrowDownLeft;
}
}
+201 -201
View File
@@ -3,231 +3,231 @@ import { cn } from "@/lib/utils";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
interface CropRegion {
x: number; // 0-1 normalized
y: number; // 0-1 normalized
width: number; // 0-1 normalized
height: number; // 0-1 normalized
x: number; // 0-1 normalized
y: number; // 0-1 normalized
width: number; // 0-1 normalized
height: number; // 0-1 normalized
}
interface CropControlProps {
videoElement: HTMLVideoElement | null;
cropRegion: CropRegion;
onCropChange: (region: CropRegion) => void;
aspectRatio: AspectRatio;
videoElement: HTMLVideoElement | null;
cropRegion: CropRegion;
onCropChange: (region: CropRegion) => void;
aspectRatio: AspectRatio;
}
type DragHandle = 'top' | 'right' | 'bottom' | 'left' | null;
type DragHandle = "top" | "right" | "bottom" | "left" | null;
export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState<DragHandle>(null);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [initialCrop, setInitialCrop] = useState<CropRegion>(cropRegion);
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState<DragHandle>(null);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [initialCrop, setInitialCrop] = useState<CropRegion>(cropRegion);
useEffect(() => {
if (!videoElement || !canvasRef.current) return;
useEffect(() => {
if (!videoElement || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) return;
canvas.width = videoElement.videoWidth || 1920;
canvas.height = videoElement.videoHeight || 1080;
canvas.width = videoElement.videoWidth || 1920;
canvas.height = videoElement.videoHeight || 1080;
const draw = () => {
if (videoElement.readyState >= 2) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
}
requestAnimationFrame(draw);
};
const draw = () => {
if (videoElement.readyState >= 2) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
}
requestAnimationFrame(draw);
};
const rafId = requestAnimationFrame(draw);
return () => cancelAnimationFrame(rafId);
}, [videoElement]);
const rafId = requestAnimationFrame(draw);
return () => cancelAnimationFrame(rafId);
}, [videoElement]);
const getContainerRect = () => {
return containerRef.current?.getBoundingClientRect() || { width: 0, height: 0, left: 0, top: 0 };
};
const getContainerRect = () => {
return (
containerRef.current?.getBoundingClientRect() || { width: 0, height: 0, left: 0, top: 0 }
);
};
const handlePointerDown = (e: React.PointerEvent, handle: DragHandle) => {
e.stopPropagation();
e.preventDefault();
setIsDragging(handle);
const rect = getContainerRect();
setDragStart({
x: (e.clientX - rect.left) / rect.width,
y: (e.clientY - rect.top) / rect.height,
});
setInitialCrop(cropRegion);
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerDown = (e: React.PointerEvent, handle: DragHandle) => {
e.stopPropagation();
e.preventDefault();
setIsDragging(handle);
const rect = getContainerRect();
setDragStart({
x: (e.clientX - rect.left) / rect.width,
y: (e.clientY - rect.top) / rect.height,
});
setInitialCrop(cropRegion);
const handlePointerMove = (e: React.PointerEvent) => {
if (!isDragging) return;
e.currentTarget.setPointerCapture(e.pointerId);
};
const rect = getContainerRect();
const currentX = (e.clientX - rect.left) / rect.width;
const currentY = (e.clientY - rect.top) / rect.height;
const deltaX = currentX - dragStart.x;
const deltaY = currentY - dragStart.y;
const handlePointerMove = (e: React.PointerEvent) => {
if (!isDragging) return;
let newCrop = { ...initialCrop };
const rect = getContainerRect();
const currentX = (e.clientX - rect.left) / rect.width;
const currentY = (e.clientY - rect.top) / rect.height;
const deltaX = currentX - dragStart.x;
const deltaY = currentY - dragStart.y;
switch (isDragging) {
case 'top': {
const newY = Math.max(0, initialCrop.y + deltaY);
const bottom = initialCrop.y + initialCrop.height;
newCrop.y = Math.min(newY, bottom - 0.1);
newCrop.height = bottom - newCrop.y;
break;
}
case 'bottom':
newCrop.height = Math.max(0.1, Math.min(initialCrop.height + deltaY, 1 - initialCrop.y));
break;
case 'left': {
const newX = Math.max(0, initialCrop.x + deltaX);
const right = initialCrop.x + initialCrop.width;
newCrop.x = Math.min(newX, right - 0.1);
newCrop.width = right - newCrop.x;
break;
}
case 'right':
newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x));
break;
}
let newCrop = { ...initialCrop };
onCropChange(newCrop);
};
switch (isDragging) {
case "top": {
const newY = Math.max(0, initialCrop.y + deltaY);
const bottom = initialCrop.y + initialCrop.height;
newCrop.y = Math.min(newY, bottom - 0.1);
newCrop.height = bottom - newCrop.y;
break;
}
case "bottom":
newCrop.height = Math.max(0.1, Math.min(initialCrop.height + deltaY, 1 - initialCrop.y));
break;
case "left": {
const newX = Math.max(0, initialCrop.x + deltaX);
const right = initialCrop.x + initialCrop.width;
newCrop.x = Math.min(newX, right - 0.1);
newCrop.width = right - newCrop.x;
break;
}
case "right":
newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x));
break;
}
const handlePointerUp = (e: React.PointerEvent) => {
if (isDragging) {
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
}
}
setIsDragging(null);
};
onCropChange(newCrop);
};
const cropPixelX = cropRegion.x * 100;
const cropPixelY = cropRegion.y * 100;
const cropPixelWidth = cropRegion.width * 100;
const cropPixelHeight = cropRegion.height * 100;
const videoAspectRatio = videoElement ? videoElement.videoWidth / videoElement.videoHeight : 16/9;
const isVideoPortrait = videoAspectRatio < 1;
const maxContainerWidth = isVideoPortrait ? '40vw' : '75vw';
const maxContainerHeight = '75vh';
const handlePointerUp = (e: React.PointerEvent) => {
if (isDragging) {
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {}
}
setIsDragging(null);
};
return (
<div className="w-full p-8">
<div
ref={containerRef}
className="relative w-full bg-black rounded-lg overflow-visible cursor-default select-none shadow-2xl"
style={{
aspectRatio: videoAspectRatio,
maxWidth: maxContainerWidth,
maxHeight: maxContainerHeight,
margin: '0 auto',
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<canvas
ref={canvasRef}
className="w-full h-full rounded-lg"
style={{ imageRendering: 'auto' }}
/>
<div className="absolute inset-0 pointer-events-none" style={{ transition: 'none' }}>
<svg width="100%" height="100%" className="absolute inset-0" style={{ transition: 'none' }}>
<defs>
<mask id="cropMask">
<rect width="100%" height="100%" fill="white" />
<rect
x={`${cropPixelX}%`}
y={`${cropPixelY}%`}
width={`${cropPixelWidth}%`}
height={`${cropPixelHeight}%`}
fill="black"
style={{ transition: 'none' }}
/>
</mask>
</defs>
<rect
width="100%"
height="100%"
fill="black"
fillOpacity="0.6"
mask="url(#cropMask)"
style={{ transition: 'none' }}
/>
</svg>
</div>
const cropPixelX = cropRegion.x * 100;
const cropPixelY = cropRegion.y * 100;
const cropPixelWidth = cropRegion.width * 100;
const cropPixelHeight = cropRegion.height * 100;
const videoAspectRatio = videoElement
? videoElement.videoWidth / videoElement.videoHeight
: 16 / 9;
const isVideoPortrait = videoAspectRatio < 1;
const maxContainerWidth = isVideoPortrait ? "40vw" : "75vw";
const maxContainerHeight = "75vh";
<div
className={cn(
"absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]"
)}
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY}%`,
width: `${cropPixelWidth}%`,
transform: 'translateY(-50%)',
willChange: 'transform',
transition: 'none',
}}
onPointerDown={(e) => handlePointerDown(e, 'top')}
/>
return (
<div className="w-full p-8">
<div
ref={containerRef}
className="relative w-full bg-black rounded-lg overflow-visible cursor-default select-none shadow-2xl"
style={{
aspectRatio: videoAspectRatio,
maxWidth: maxContainerWidth,
maxHeight: maxContainerHeight,
margin: "0 auto",
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<canvas
ref={canvasRef}
className="w-full h-full rounded-lg"
style={{ imageRendering: "auto" }}
/>
<div
className={cn(
"absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]"
)}
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY + cropPixelHeight}%`,
width: `${cropPixelWidth}%`,
transform: 'translateY(-50%)',
willChange: 'transform',
transition: 'none',
}}
onPointerDown={(e) => handlePointerDown(e, 'bottom')}
/>
<div className="absolute inset-0 pointer-events-none" style={{ transition: "none" }}>
<svg
width="100%"
height="100%"
className="absolute inset-0"
style={{ transition: "none" }}
>
<defs>
<mask id="cropMask">
<rect width="100%" height="100%" fill="white" />
<rect
x={`${cropPixelX}%`}
y={`${cropPixelY}%`}
width={`${cropPixelWidth}%`}
height={`${cropPixelHeight}%`}
fill="black"
style={{ transition: "none" }}
/>
</mask>
</defs>
<rect
width="100%"
height="100%"
fill="black"
fillOpacity="0.6"
mask="url(#cropMask)"
style={{ transition: "none" }}
/>
</svg>
</div>
<div
className={cn(
"absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#34B27B]"
)}
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY}%`,
height: `${cropPixelHeight}%`,
transform: 'translateX(-50%)',
willChange: 'transform',
transition: 'none',
}}
onPointerDown={(e) => handlePointerDown(e, 'left')}
/>
<div
className={cn("absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]")}
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY}%`,
width: `${cropPixelWidth}%`,
transform: "translateY(-50%)",
willChange: "transform",
transition: "none",
}}
onPointerDown={(e) => handlePointerDown(e, "top")}
/>
<div
className={cn(
"absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#34B27B]"
)}
style={{
left: `${cropPixelX + cropPixelWidth}%`,
top: `${cropPixelY}%`,
height: `${cropPixelHeight}%`,
transform: 'translateX(-50%)',
willChange: 'transform',
transition: 'none',
}}
onPointerDown={(e) => handlePointerDown(e, 'right')}
/>
</div>
</div>
);
<div
className={cn("absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]")}
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY + cropPixelHeight}%`,
width: `${cropPixelWidth}%`,
transform: "translateY(-50%)",
willChange: "transform",
transition: "none",
}}
onPointerDown={(e) => handlePointerDown(e, "bottom")}
/>
<div
className={cn("absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#34B27B]")}
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY}%`,
height: `${cropPixelHeight}%`,
transform: "translateX(-50%)",
willChange: "transform",
transition: "none",
}}
onPointerDown={(e) => handlePointerDown(e, "left")}
/>
<div
className={cn("absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#34B27B]")}
style={{
left: `${cropPixelX + cropPixelWidth}%`,
top: `${cropPixelY}%`,
height: `${cropPixelHeight}%`,
transform: "translateX(-50%)",
willChange: "transform",
transition: "none",
}}
onPointerDown={(e) => handlePointerDown(e, "right")}
/>
</div>
</div>
);
}
+246 -248
View File
@@ -1,273 +1,271 @@
import { useEffect, useState } from 'react';
import { X, Download, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { ExportProgress } from '@/lib/exporter';
import { toast } from 'sonner'; // Add this import
import { Download, Loader2, X } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner"; // Add this import
import { Button } from "@/components/ui/button";
import type { ExportProgress } from "@/lib/exporter";
interface ExportDialogProps {
isOpen: boolean;
onClose: () => void;
progress: ExportProgress | null;
isExporting: boolean;
error: string | null;
onCancel?: () => void;
exportFormat?: 'mp4' | 'gif';
exportedFilePath?: string;
isOpen: boolean;
onClose: () => void;
progress: ExportProgress | null;
isExporting: boolean;
error: string | null;
onCancel?: () => void;
exportFormat?: "mp4" | "gif";
exportedFilePath?: string;
}
export function ExportDialog({
isOpen,
onClose,
progress,
isExporting,
error,
onCancel,
exportFormat = 'mp4',
exportedFilePath, // Add this line
isOpen,
onClose,
progress,
isExporting,
error,
onCancel,
exportFormat = "mp4",
exportedFilePath, // Add this line
}: ExportDialogProps) {
const [showSuccess, setShowSuccess] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
// Reset showSuccess when a new export starts or dialog reopens
useEffect(() => {
if (isExporting) {
setShowSuccess(false);
}
}, [isExporting]);
// Reset showSuccess when a new export starts or dialog reopens
useEffect(() => {
if (isExporting) {
setShowSuccess(false);
}
}, [isExporting]);
// Reset showSuccess when dialog opens fresh
useEffect(() => {
if (isOpen && !isExporting && !progress) {
setShowSuccess(false);
}
}, [isOpen, isExporting, progress]);
// Reset showSuccess when dialog opens fresh
useEffect(() => {
if (isOpen && !isExporting && !progress) {
setShowSuccess(false);
}
}, [isOpen, isExporting, progress]);
useEffect(() => {
if (!isExporting && progress && progress.percentage >= 100 && !error) {
setShowSuccess(true);
const timer = setTimeout(() => {
setShowSuccess(false);
onClose();
}, 2000);
return () => clearTimeout(timer);
}
}, [isExporting, progress, error, onClose]);
useEffect(() => {
if (!isExporting && progress && progress.percentage >= 100 && !error) {
setShowSuccess(true);
const timer = setTimeout(() => {
setShowSuccess(false);
onClose();
}, 2000);
return () => clearTimeout(timer);
}
}, [isExporting, progress, error, onClose]);
if (!isOpen) return null;
if (!isOpen) return null;
const formatLabel = exportFormat === 'gif' ? 'GIF' : 'Video';
// Determine if we're in the compiling phase (frames done but still exporting)
const isCompiling = isExporting && progress && progress.percentage >= 100 && exportFormat === 'gif';
const isFinalizing = progress?.phase === 'finalizing';
const renderProgress = progress?.renderProgress;
// Get status message based on phase
const getStatusMessage = () => {
if (error) return 'Please try again';
if (isCompiling || isFinalizing) {
if (renderProgress !== undefined && renderProgress > 0) {
return `Compiling GIF... ${renderProgress}%`;
}
return 'Compiling GIF... This may take a while';
}
return 'This may take a moment...';
};
const formatLabel = exportFormat === "gif" ? "GIF" : "Video";
// Get title based on phase
const getTitle = () => {
if (error) return 'Export Failed';
if (isCompiling || isFinalizing) return 'Compiling GIF';
return `Exporting ${formatLabel}`;
};
// Determine if we're in the compiling phase (frames done but still exporting)
const isCompiling =
isExporting && progress && progress.percentage >= 100 && exportFormat === "gif";
const isFinalizing = progress?.phase === "finalizing";
const renderProgress = progress?.renderProgress;
const handleClickShowInFolder = async () => {
if (exportedFilePath) {
try {
const result = await window.electronAPI.revealInFolder(exportedFilePath);
if (!result.success) {
const errorMessage = result.error || result.message || 'Failed to reveal item in folder.';
console.error('Failed to reveal in folder:', errorMessage);
toast.error(errorMessage);
}
} catch (err) {
const errorMessage = String(err);
console.error('Error calling revealInFolder IPC:', errorMessage);
toast.error(`Error revealing in folder: ${errorMessage}`);
}
}
};
// Get status message based on phase
const getStatusMessage = () => {
if (error) return "Please try again";
if (isCompiling || isFinalizing) {
if (renderProgress !== undefined && renderProgress > 0) {
return `Compiling GIF... ${renderProgress}%`;
}
return "Compiling GIF... This may take a while";
}
return "This may take a moment...";
};
return (
<>
<div
className="fixed inset-0 bg-black/80 backdrop-blur-md z-50 animate-in fade-in duration-200"
onClick={isExporting ? undefined : onClose}
/>
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-md animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
{showSuccess ? (
<>
<div className="w-12 h-12 rounded-full bg-[#34B27B]/20 flex items-center justify-center ring-1 ring-[#34B27B]/50">
<Download className="w-6 h-6 text-[#34B27B]" />
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold text-slate-200 block">Export Complete</span>
<span className="text-sm text-slate-400">Your {formatLabel.toLowerCase()} is ready</span>
{exportedFilePath && (
<Button
variant="secondary"
onClick={handleClickShowInFolder}
className="mt-2 w-fit px-3 py-1 text-sm rounded-md bg-white/10 hover:bg-white/20 text-slate-200"
>
Show in Folder
</Button>
)}
{exportedFilePath && (
<span className="text-xs text-slate-500 break-all max-w-xs mt-1">
{exportedFilePath.split('/').pop()}
</span>
)}
</div>
</>
) : (
<>
{isExporting ? (
<div className="w-12 h-12 rounded-full bg-[#34B27B]/10 flex items-center justify-center">
<Loader2 className="w-6 h-6 text-[#34B27B] animate-spin" />
</div>
) : (
<div className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center border border-white/10">
<Download className="w-6 h-6 text-slate-200" />
</div>
)}
<div>
<span className="text-xl font-bold text-slate-200 block">
{getTitle()}
</span>
<span className="text-sm text-slate-400">
{getStatusMessage()}
</span>
</div>
</>
)}
</div>
{!isExporting && (
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="hover:bg-white/10 text-slate-400 hover:text-white rounded-full"
>
<X className="w-5 h-5" />
</Button>
)}
</div>
// Get title based on phase
const getTitle = () => {
if (error) return "Export Failed";
if (isCompiling || isFinalizing) return "Compiling GIF";
return `Exporting ${formatLabel}`;
};
{error && (
<div className="mb-6 animate-in slide-in-from-top-2">
<div className="bg-red-500/10 border border-red-500/20 rounded-xl p-4 flex items-start gap-3">
<div className="p-1 bg-red-500/20 rounded-full">
<X className="w-3 h-3 text-red-400" />
</div>
<p className="text-sm text-red-400 leading-relaxed">{error}</p>
</div>
</div>
)}
const handleClickShowInFolder = async () => {
if (exportedFilePath) {
try {
const result = await window.electronAPI.revealInFolder(exportedFilePath);
if (!result.success) {
const errorMessage = result.error || result.message || "Failed to reveal item in folder.";
console.error("Failed to reveal in folder:", errorMessage);
toast.error(errorMessage);
}
} catch (err) {
const errorMessage = String(err);
console.error("Error calling revealInFolder IPC:", errorMessage);
toast.error(`Error revealing in folder: ${errorMessage}`);
}
}
};
{isExporting && progress && (
<div className="space-y-6">
<div className="space-y-2">
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
<span>{isCompiling || isFinalizing ? 'Compiling' : 'Rendering Frames'}</span>
<span className="font-mono text-slate-200">
{isCompiling || isFinalizing ? (
renderProgress !== undefined && renderProgress > 0 ? (
`${renderProgress}%`
) : (
<span className="flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin" />
Processing...
</span>
)
) : (
`${progress.percentage.toFixed(0)}%`
)}
</span>
</div>
<div className="h-2 bg-white/5 rounded-full overflow-hidden border border-white/5">
{isCompiling || isFinalizing ? (
// Show render progress if available, otherwise animated indeterminate bar
renderProgress !== undefined && renderProgress > 0 ? (
<div
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
style={{ width: `${renderProgress}%` }}
/>
) : (
<div className="h-full w-full relative overflow-hidden">
<div
className="absolute h-full w-1/3 bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)]"
style={{
animation: 'indeterminate 1.5s ease-in-out infinite',
}}
/>
<style>{`
return (
<>
<div
className="fixed inset-0 bg-black/80 backdrop-blur-md z-50 animate-in fade-in duration-200"
onClick={isExporting ? undefined : onClose}
/>
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-md animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
{showSuccess ? (
<>
<div className="w-12 h-12 rounded-full bg-[#34B27B]/20 flex items-center justify-center ring-1 ring-[#34B27B]/50">
<Download className="w-6 h-6 text-[#34B27B]" />
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold text-slate-200 block">Export Complete</span>
<span className="text-sm text-slate-400">
Your {formatLabel.toLowerCase()} is ready
</span>
{exportedFilePath && (
<Button
variant="secondary"
onClick={handleClickShowInFolder}
className="mt-2 w-fit px-3 py-1 text-sm rounded-md bg-white/10 hover:bg-white/20 text-slate-200"
>
Show in Folder
</Button>
)}
{exportedFilePath && (
<span className="text-xs text-slate-500 break-all max-w-xs mt-1">
{exportedFilePath.split("/").pop()}
</span>
)}
</div>
</>
) : (
<>
{isExporting ? (
<div className="w-12 h-12 rounded-full bg-[#34B27B]/10 flex items-center justify-center">
<Loader2 className="w-6 h-6 text-[#34B27B] animate-spin" />
</div>
) : (
<div className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center border border-white/10">
<Download className="w-6 h-6 text-slate-200" />
</div>
)}
<div>
<span className="text-xl font-bold text-slate-200 block">{getTitle()}</span>
<span className="text-sm text-slate-400">{getStatusMessage()}</span>
</div>
</>
)}
</div>
{!isExporting && (
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="hover:bg-white/10 text-slate-400 hover:text-white rounded-full"
>
<X className="w-5 h-5" />
</Button>
)}
</div>
{error && (
<div className="mb-6 animate-in slide-in-from-top-2">
<div className="bg-red-500/10 border border-red-500/20 rounded-xl p-4 flex items-start gap-3">
<div className="p-1 bg-red-500/20 rounded-full">
<X className="w-3 h-3 text-red-400" />
</div>
<p className="text-sm text-red-400 leading-relaxed">{error}</p>
</div>
</div>
)}
{isExporting && progress && (
<div className="space-y-6">
<div className="space-y-2">
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
<span>{isCompiling || isFinalizing ? "Compiling" : "Rendering Frames"}</span>
<span className="font-mono text-slate-200">
{isCompiling || isFinalizing ? (
renderProgress !== undefined && renderProgress > 0 ? (
`${renderProgress}%`
) : (
<span className="flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin" />
Processing...
</span>
)
) : (
`${progress.percentage.toFixed(0)}%`
)}
</span>
</div>
<div className="h-2 bg-white/5 rounded-full overflow-hidden border border-white/5">
{isCompiling || isFinalizing ? (
// Show render progress if available, otherwise animated indeterminate bar
renderProgress !== undefined && renderProgress > 0 ? (
<div
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
style={{ width: `${renderProgress}%` }}
/>
) : (
<div className="h-full w-full relative overflow-hidden">
<div
className="absolute h-full w-1/3 bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)]"
style={{
animation: "indeterminate 1.5s ease-in-out infinite",
}}
/>
<style>{`
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
`}</style>
</div>
)
) : (
<div
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
style={{ width: `${Math.min(progress.percentage, 100)}%` }}
/>
)}
</div>
</div>
</div>
)
) : (
<div
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
style={{ width: `${Math.min(progress.percentage, 100)}%` }}
/>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
{isCompiling || isFinalizing ? 'Status' : 'Format'}
</div>
<div className="text-slate-200 font-medium text-sm">
{isCompiling || isFinalizing ? 'Compiling...' : formatLabel}
</div>
</div>
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Frames</div>
<div className="text-slate-200 font-medium text-sm">
{progress.currentFrame} / {progress.totalFrames}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
{isCompiling || isFinalizing ? "Status" : "Format"}
</div>
<div className="text-slate-200 font-medium text-sm">
{isCompiling || isFinalizing ? "Compiling..." : formatLabel}
</div>
</div>
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
Frames
</div>
<div className="text-slate-200 font-medium text-sm">
{progress.currentFrame} / {progress.totalFrames}
</div>
</div>
</div>
{onCancel && (
<div className="pt-2">
<Button
onClick={onCancel}
variant="destructive"
className="w-full py-6 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all rounded-xl"
>
Cancel Export
</Button>
</div>
)}
</div>
)}
{onCancel && (
<div className="pt-2">
<Button
onClick={onCancel}
variant="destructive"
className="w-full py-6 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all rounded-xl"
>
Cancel Export
</Button>
</div>
)}
</div>
)}
{showSuccess && (
<div className="text-center py-4 animate-in zoom-in-95">
<p className="text-lg text-slate-200 font-medium">
{formatLabel} saved successfully!
</p>
</div>
)}
</div>
</>
);
{showSuccess && (
<div className="text-center py-4 animate-in zoom-in-95">
<p className="text-lg text-slate-200 font-medium">{formatLabel} saved successfully!</p>
</div>
)}
</div>
</>
);
}
+64 -64
View File
@@ -1,77 +1,77 @@
import { Film, Image } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ExportFormat } from '@/lib/exporter/types';
import { Film, Image } from "lucide-react";
import type { ExportFormat } from "@/lib/exporter/types";
import { cn } from "@/lib/utils";
interface FormatSelectorProps {
selectedFormat: ExportFormat;
onFormatChange: (format: ExportFormat) => void;
disabled?: boolean;
selectedFormat: ExportFormat;
onFormatChange: (format: ExportFormat) => void;
disabled?: boolean;
}
interface FormatOption {
value: ExportFormat;
label: string;
description: string;
icon: React.ReactNode;
value: ExportFormat;
label: string;
description: string;
icon: React.ReactNode;
}
const formatOptions: FormatOption[] = [
{
value: 'mp4',
label: 'MP4 Video',
description: 'High quality video file',
icon: <Film className="w-5 h-5" />,
},
{
value: 'gif',
label: 'GIF Animation',
description: 'Animated image for sharing',
icon: <Image className="w-5 h-5" />,
},
{
value: "mp4",
label: "MP4 Video",
description: "High quality video file",
icon: <Film className="w-5 h-5" />,
},
{
value: "gif",
label: "GIF Animation",
description: "Animated image for sharing",
icon: <Image className="w-5 h-5" />,
},
];
export function FormatSelector({
selectedFormat,
onFormatChange,
disabled = false,
selectedFormat,
onFormatChange,
disabled = false,
}: FormatSelectorProps) {
return (
<div className="grid grid-cols-2 gap-3">
{formatOptions.map((option) => {
const isSelected = selectedFormat === option.value;
return (
<button
key={option.value}
type="button"
disabled={disabled}
onClick={() => onFormatChange(option.value)}
className={cn(
'relative flex flex-col items-center gap-2 p-4 rounded-xl border transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-[#34B27B]/50 focus:ring-offset-2 focus:ring-offset-[#09090b]',
isSelected
? 'bg-[#34B27B]/10 border-[#34B27B]/50 text-white'
: 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20 hover:text-slate-200',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
<div
className={cn(
'w-10 h-10 rounded-full flex items-center justify-center transition-colors',
isSelected ? 'bg-[#34B27B]/20 text-[#34B27B]' : 'bg-white/5'
)}
>
{option.icon}
</div>
<div className="text-center">
<div className="font-medium text-sm">{option.label}</div>
<div className="text-xs text-slate-500 mt-0.5">{option.description}</div>
</div>
{isSelected && (
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34B27B]" />
)}
</button>
);
})}
</div>
);
return (
<div className="grid grid-cols-2 gap-3">
{formatOptions.map((option) => {
const isSelected = selectedFormat === option.value;
return (
<button
key={option.value}
type="button"
disabled={disabled}
onClick={() => onFormatChange(option.value)}
className={cn(
"relative flex flex-col items-center gap-2 p-4 rounded-xl border transition-all duration-200",
"focus:outline-none focus:ring-2 focus:ring-[#34B27B]/50 focus:ring-offset-2 focus:ring-offset-[#09090b]",
isSelected
? "bg-[#34B27B]/10 border-[#34B27B]/50 text-white"
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20 hover:text-slate-200",
disabled && "opacity-50 cursor-not-allowed",
)}
>
<div
className={cn(
"w-10 h-10 rounded-full flex items-center justify-center transition-colors",
isSelected ? "bg-[#34B27B]/20 text-[#34B27B]" : "bg-white/5",
)}
>
{option.icon}
</div>
<div className="text-center">
<div className="font-medium text-sm">{option.label}</div>
<div className="text-xs text-slate-500 mt-0.5">{option.description}</div>
</div>
{isSelected && (
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34B27B]" />
)}
</button>
);
})}
</div>
);
}
+100 -99
View File
@@ -1,110 +1,111 @@
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS, type GifFrameRate, type GifSizePreset } from '@/lib/exporter/types';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
GIF_FRAME_RATES,
GIF_SIZE_PRESETS,
type GifFrameRate,
type GifSizePreset,
} from "@/lib/exporter/types";
interface GifOptionsPanelProps {
frameRate: GifFrameRate;
onFrameRateChange: (rate: GifFrameRate) => void;
loop: boolean;
onLoopChange: (loop: boolean) => void;
sizePreset: GifSizePreset;
onSizePresetChange: (preset: GifSizePreset) => void;
outputDimensions: { width: number; height: number };
disabled?: boolean;
frameRate: GifFrameRate;
onFrameRateChange: (rate: GifFrameRate) => void;
loop: boolean;
onLoopChange: (loop: boolean) => void;
sizePreset: GifSizePreset;
onSizePresetChange: (preset: GifSizePreset) => void;
outputDimensions: { width: number; height: number };
disabled?: boolean;
}
export function GifOptionsPanel({
frameRate,
onFrameRateChange,
loop,
onLoopChange,
sizePreset,
onSizePresetChange,
outputDimensions,
disabled = false,
frameRate,
onFrameRateChange,
loop,
onLoopChange,
sizePreset,
onSizePresetChange,
outputDimensions,
disabled = false,
}: GifOptionsPanelProps) {
const sizePresetOptions = Object.entries(GIF_SIZE_PRESETS).map(([key, value]) => ({
value: key as GifSizePreset,
label: value.label,
}));
const sizePresetOptions = Object.entries(GIF_SIZE_PRESETS).map(([key, value]) => ({
value: key as GifSizePreset,
label: value.label,
}));
return (
<div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200">
{/* Frame Rate */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
Frame Rate
</label>
<Select
value={String(frameRate)}
onValueChange={(value) => onFrameRateChange(Number(value) as GifFrameRate)}
disabled={disabled}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
{GIF_FRAME_RATES.map((rate) => (
<SelectItem
key={rate.value}
value={String(rate.value)}
className="text-slate-200 focus:bg-white/10 focus:text-white"
>
{rate.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
return (
<div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200">
{/* Frame Rate */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
Frame Rate
</label>
<Select
value={String(frameRate)}
onValueChange={(value) => onFrameRateChange(Number(value) as GifFrameRate)}
disabled={disabled}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
{GIF_FRAME_RATES.map((rate) => (
<SelectItem
key={rate.value}
value={String(rate.value)}
className="text-slate-200 focus:bg-white/10 focus:text-white"
>
{rate.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Size Preset */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
Output Size
</label>
<Select
value={sizePreset}
onValueChange={(value) => onSizePresetChange(value as GifSizePreset)}
disabled={disabled}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
{sizePresetOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className="text-slate-200 focus:bg-white/10 focus:text-white"
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-slate-500">
Output: {outputDimensions.width} × {outputDimensions.height}px
</div>
</div>
{/* Size Preset */}
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
Output Size
</label>
<Select
value={sizePreset}
onValueChange={(value) => onSizePresetChange(value as GifSizePreset)}
disabled={disabled}
>
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
{sizePresetOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className="text-slate-200 focus:bg-white/10 focus:text-white"
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-slate-500">
Output: {outputDimensions.width} × {outputDimensions.height}px
</div>
</div>
{/* Loop Toggle */}
<div className="flex items-center justify-between py-2">
<div>
<label className="text-sm font-medium text-slate-200">Loop Animation</label>
<p className="text-xs text-slate-500">GIF will play continuously</p>
</div>
<Switch
checked={loop}
onCheckedChange={onLoopChange}
disabled={disabled}
/>
</div>
</div>
);
{/* Loop Toggle */}
<div className="flex items-center justify-between py-2">
<div>
<label className="text-sm font-medium text-slate-200">Loop Animation</label>
<p className="text-xs text-slate-500">GIF will play continuously</p>
</div>
<Switch checked={loop} onCheckedChange={onLoopChange} disabled={disabled} />
</div>
</div>
);
}
@@ -1,65 +1,74 @@
import { HelpCircle, Settings2 } from "lucide-react";
import { useState, useEffect } from "react";
import { formatShortcut } from "@/utils/platformUtils";
import { useEffect, useState } from "react";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { formatBinding, SHORTCUT_LABELS, SHORTCUT_ACTIONS } from "@/lib/shortcuts";
import { formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
import { formatShortcut } from "@/utils/platformUtils";
export function KeyboardShortcutsHelp() {
const { shortcuts, isMac, openConfig } = useShortcuts();
const { shortcuts, isMac, openConfig } = useShortcuts();
const [scrollLabels, setScrollLabels] = useState({ pan: 'Shift + Ctrl + Scroll', zoom: 'Ctrl + Scroll' });
const [scrollLabels, setScrollLabels] = useState({
pan: "Shift + Ctrl + Scroll",
zoom: "Ctrl + Scroll",
});
useEffect(() => {
Promise.all([
formatShortcut(['shift', 'mod', 'Scroll']),
formatShortcut(['mod', 'Scroll']),
]).then(([pan, zoom]) => setScrollLabels({ pan, zoom }));
}, []);
useEffect(() => {
Promise.all([
formatShortcut(["shift", "mod", "Scroll"]),
formatShortcut(["mod", "Scroll"]),
]).then(([pan, zoom]) => setScrollLabels({ pan, zoom }));
}, []);
return (
<div className="relative group">
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
return (
<div className="relative group">
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-slate-200">Keyboard Shortcuts</span>
<button
type="button"
onClick={openConfig}
title="Customize shortcuts"
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
>
<Settings2 className="w-3 h-3" />
Customize
</button>
</div>
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-slate-200">Keyboard Shortcuts</span>
<button
type="button"
onClick={openConfig}
title="Customize shortcuts"
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
>
<Settings2 className="w-3 h-3" />
Customize
</button>
</div>
<div className="space-y-1.5 text-[10px]">
{SHORTCUT_ACTIONS.map((action) => (
<div key={action} className="flex items-center justify-between">
<span className="text-slate-400">{SHORTCUT_LABELS[action]}</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{formatBinding(shortcuts[action], isMac)}
</kbd>
</div>
))}
<div className="space-y-1.5 text-[10px]">
{SHORTCUT_ACTIONS.map((action) => (
<div key={action} className="flex items-center justify-between">
<span className="text-slate-400">{SHORTCUT_LABELS[action]}</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{formatBinding(shortcuts[action], isMac)}
</kbd>
</div>
))}
<div className="pt-1 border-t border-white/5 mt-1">
<div className="flex items-center justify-between">
<span className="text-slate-400">Pan Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{scrollLabels.pan}</kbd>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-slate-400">Zoom Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{scrollLabels.zoom}</kbd>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-slate-400">Cycle Annotations</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Tab</kbd>
</div>
</div>
</div>
</div>
</div>
);
<div className="pt-1 border-t border-white/5 mt-1">
<div className="flex items-center justify-between">
<span className="text-slate-400">Pan Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{scrollLabels.pan}
</kbd>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-slate-400">Zoom Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{scrollLabels.zoom}
</kbd>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-slate-400">Cycle Annotations</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
Tab
</kbd>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -1,92 +1,89 @@
import { Button } from "../ui/button";
import { Play, Pause } from "lucide-react";
import { Pause, Play } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
interface PlaybackControlsProps {
isPlaying: boolean;
currentTime: number;
duration: number;
onTogglePlayPause: () => void;
onSeek: (time: number) => void;
isPlaying: boolean;
currentTime: number;
duration: number;
onTogglePlayPause: () => void;
onSeek: (time: number) => void;
}
export default function PlaybackControls({
isPlaying,
currentTime,
duration,
onTogglePlayPause,
onSeek,
isPlaying,
currentTime,
duration,
onTogglePlayPause,
onSeek,
}: PlaybackControlsProps) {
function formatTime(seconds: number) {
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function formatTime(seconds: number) {
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return "0:00";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function handleSeekChange(e: React.ChangeEvent<HTMLInputElement>) {
onSeek(parseFloat(e.target.value));
}
function handleSeekChange(e: React.ChangeEvent<HTMLInputElement>) {
onSeek(parseFloat(e.target.value));
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div className="flex items-center gap-2 px-1 py-0.5 rounded-full bg-black/60 backdrop-blur-md border border-white/10 shadow-xl transition-all duration-300 hover:bg-black/70 hover:border-white/20">
<Button
onClick={onTogglePlayPause}
size="icon"
className={cn(
"w-8 h-8 rounded-full transition-all duration-200 border border-white/10",
isPlaying
? "bg-white/10 text-white hover:bg-white/20"
: "bg-white text-black hover:bg-white/90 hover:scale-105 shadow-[0_0_15px_rgba(255,255,255,0.3)]"
)}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? (
<Pause className="w-3.5 h-3.5 fill-current" />
) : (
<Play className="w-3.5 h-3.5 fill-current ml-0.5" />
)}
</Button>
<span className="text-[9px] font-medium text-slate-300 tabular-nums w-[30px] text-right">
{formatTime(currentTime)}
</span>
<div className="flex-1 relative h-6 flex items-center group">
{/* Custom Track Background */}
<div className="absolute left-0 right-0 h-0.5 bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-[#34B27B] rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
{/* Interactive Input */}
<input
type="range"
min="0"
max={duration || 100}
value={currentTime}
onChange={handleSeekChange}
step="0.01"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
/>
{/* Custom Thumb (visual only, follows progress) */}
<div
className="absolute w-2.5 h-2.5 bg-white rounded-full shadow-lg pointer-events-none group-hover:scale-125 transition-transform duration-100"
style={{
left: `${progress}%`,
transform: 'translateX(-50%)'
}}
/>
</div>
<span className="text-[9px] font-medium text-slate-500 tabular-nums w-[30px]">
{formatTime(duration)}
</span>
</div>
);
return (
<div className="flex items-center gap-2 px-1 py-0.5 rounded-full bg-black/60 backdrop-blur-md border border-white/10 shadow-xl transition-all duration-300 hover:bg-black/70 hover:border-white/20">
<Button
onClick={onTogglePlayPause}
size="icon"
className={cn(
"w-8 h-8 rounded-full transition-all duration-200 border border-white/10",
isPlaying
? "bg-white/10 text-white hover:bg-white/20"
: "bg-white text-black hover:bg-white/90 hover:scale-105 shadow-[0_0_15px_rgba(255,255,255,0.3)]",
)}
aria-label={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? (
<Pause className="w-3.5 h-3.5 fill-current" />
) : (
<Play className="w-3.5 h-3.5 fill-current ml-0.5" />
)}
</Button>
<span className="text-[9px] font-medium text-slate-300 tabular-nums w-[30px] text-right">
{formatTime(currentTime)}
</span>
<div className="flex-1 relative h-6 flex items-center group">
{/* Custom Track Background */}
<div className="absolute left-0 right-0 h-0.5 bg-white/10 rounded-full overflow-hidden">
<div className="h-full bg-[#34B27B] rounded-full" style={{ width: `${progress}%` }} />
</div>
{/* Interactive Input */}
<input
type="range"
min="0"
max={duration || 100}
value={currentTime}
onChange={handleSeekChange}
step="0.01"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
/>
{/* Custom Thumb (visual only, follows progress) */}
<div
className="absolute w-2.5 h-2.5 bg-white rounded-full shadow-lg pointer-events-none group-hover:scale-125 transition-transform duration-100"
style={{
left: `${progress}%`,
transform: "translateX(-50%)",
}}
/>
</div>
<span className="text-[9px] font-medium text-slate-500 tabular-nums w-[30px]">
{formatTime(duration)}
</span>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,223 +1,242 @@
import { useCallback, useEffect, useState } from 'react';
import { Keyboard, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Keyboard, RotateCcw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DEFAULT_SHORTCUTS,
FIXED_SHORTCUTS,
SHORTCUT_ACTIONS,
SHORTCUT_LABELS,
findConflict,
formatBinding,
type ShortcutAction,
type ShortcutBinding,
type ShortcutConflict,
type ShortcutsConfig,
} from '@/lib/shortcuts';
import { useShortcuts } from '@/contexts/ShortcutsContext';
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import {
DEFAULT_SHORTCUTS,
FIXED_SHORTCUTS,
findConflict,
formatBinding,
SHORTCUT_ACTIONS,
SHORTCUT_LABELS,
type ShortcutAction,
type ShortcutBinding,
type ShortcutConflict,
type ShortcutsConfig,
} from "@/lib/shortcuts";
const MODIFIER_KEYS = new Set(['Control', 'Shift', 'Alt', 'Meta']);
const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta"]);
export function ShortcutsConfigDialog() {
const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
useShortcuts();
const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
useShortcuts();
const [draft, setDraft] = useState<ShortcutsConfig>(shortcuts);
const [captureFor, setCaptureFor] = useState<ShortcutAction | null>(null);
const [conflict, setConflict] = useState<{ forAction: ShortcutAction; pending: ShortcutBinding; conflictWith: ShortcutConflict } | null>(null);
const [draft, setDraft] = useState<ShortcutsConfig>(shortcuts);
const [captureFor, setCaptureFor] = useState<ShortcutAction | null>(null);
const [conflict, setConflict] = useState<{
forAction: ShortcutAction;
pending: ShortcutBinding;
conflictWith: ShortcutConflict;
} | null>(null);
useEffect(() => {
if (isConfigOpen) {
setDraft(shortcuts);
setCaptureFor(null);
setConflict(null);
}
}, [isConfigOpen, shortcuts]);
useEffect(() => {
if (isConfigOpen) {
setDraft(shortcuts);
setCaptureFor(null);
setConflict(null);
}
}, [isConfigOpen, shortcuts]);
useEffect(() => {
if (!captureFor) return;
useEffect(() => {
if (!captureFor) return;
const handleCapture = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
const handleCapture = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') {
setCaptureFor(null);
return;
}
if (e.key === "Escape") {
setCaptureFor(null);
return;
}
if (MODIFIER_KEYS.has(e.key)) return;
if (MODIFIER_KEYS.has(e.key)) return;
const binding: ShortcutBinding = {
key: e.key.toLowerCase(),
...(e.ctrlKey || e.metaKey ? { ctrl: true } : {}),
...(e.shiftKey ? { shift: true } : {}),
...(e.altKey ? { alt: true } : {}),
};
const binding: ShortcutBinding = {
key: e.key.toLowerCase(),
...(e.ctrlKey || e.metaKey ? { ctrl: true } : {}),
...(e.shiftKey ? { shift: true } : {}),
...(e.altKey ? { alt: true } : {}),
};
const found = findConflict(binding, captureFor, draft);
setCaptureFor(null);
const found = findConflict(binding, captureFor, draft);
setCaptureFor(null);
if (found?.type === 'fixed') {
toast.error(`This shortcut is reserved for "${found.label}" and cannot be reassigned.`);
return;
}
if (found?.type === "fixed") {
toast.error(`This shortcut is reserved for "${found.label}" and cannot be reassigned.`);
return;
}
if (found?.type === 'configurable') {
setConflict({ forAction: captureFor, pending: binding, conflictWith: found });
return;
}
if (found?.type === "configurable") {
setConflict({ forAction: captureFor, pending: binding, conflictWith: found });
return;
}
setDraft((prev: ShortcutsConfig) => ({ ...prev, [captureFor]: binding }));
};
setDraft((prev: ShortcutsConfig) => ({ ...prev, [captureFor]: binding }));
};
window.addEventListener('keydown', handleCapture, { capture: true });
return () => window.removeEventListener('keydown', handleCapture, { capture: true });
}, [captureFor]);
window.addEventListener("keydown", handleCapture, { capture: true });
return () => window.removeEventListener("keydown", handleCapture, { capture: true });
}, [captureFor]);
const handleSwap = useCallback(() => {
if (!conflict || conflict.conflictWith.type !== 'configurable') return;
const { forAction, pending, conflictWith } = conflict;
setDraft((prev: ShortcutsConfig) => ({
...prev,
[forAction]: pending,
[conflictWith.action]: prev[forAction],
}));
setConflict(null);
}, [conflict]);
const handleSwap = useCallback(() => {
if (!conflict || conflict.conflictWith.type !== "configurable") return;
const { forAction, pending, conflictWith } = conflict;
setDraft((prev: ShortcutsConfig) => ({
...prev,
[forAction]: pending,
[conflictWith.action]: prev[forAction],
}));
setConflict(null);
}, [conflict]);
const handleCancelConflict = useCallback(() => setConflict(null), []);
const handleCancelConflict = useCallback(() => setConflict(null), []);
const handleSave = useCallback(async () => {
setShortcuts(draft);
await persistShortcuts(draft);
toast.success('Keyboard shortcuts saved');
closeConfig();
}, [draft, setShortcuts, persistShortcuts, closeConfig]);
const handleSave = useCallback(async () => {
setShortcuts(draft);
await persistShortcuts(draft);
toast.success("Keyboard shortcuts saved");
closeConfig();
}, [draft, setShortcuts, persistShortcuts, closeConfig]);
const handleReset = useCallback(() => {
setDraft({ ...DEFAULT_SHORTCUTS });
toast.info('Reset to default shortcuts — click Save to apply');
}, []);
const handleReset = useCallback(() => {
setDraft({ ...DEFAULT_SHORTCUTS });
toast.info("Reset to default shortcuts — click Save to apply");
}, []);
const handleClose = useCallback(() => {
setCaptureFor(null);
setConflict(null);
closeConfig();
}, [closeConfig]);
const handleClose = useCallback(() => {
setCaptureFor(null);
setConflict(null);
closeConfig();
}, [closeConfig]);
return (
<Dialog open={isConfigOpen} onOpenChange={(open: boolean) => { if (!open) handleClose(); }}>
<DialogContent className="bg-[#09090b] border-white/10 text-white max-w-[420px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
<Keyboard className="w-4 h-4 text-[#34B27B]" />
Keyboard Shortcuts
</DialogTitle>
</DialogHeader>
return (
<Dialog
open={isConfigOpen}
onOpenChange={(open: boolean) => {
if (!open) handleClose();
}}
>
<DialogContent className="bg-[#09090b] border-white/10 text-white max-w-[420px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
<Keyboard className="w-4 h-4 text-[#34B27B]" />
Keyboard Shortcuts
</DialogTitle>
</DialogHeader>
<div className="space-y-0.5">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">Configurable</p>
{SHORTCUT_ACTIONS.map((action) => {
const isCapturing = captureFor === action;
const hasConflict = conflict?.forAction === action;
return (
<div key={action}>
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
<span className="text-sm text-slate-300">{SHORTCUT_LABELS[action]}</span>
<button
type="button"
onClick={() => {
setConflict(null);
setCaptureFor(isCapturing ? null : action);
}}
title={isCapturing ? 'Press Esc to cancel' : 'Click to change'}
className={[
'px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none',
isCapturing
? 'bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse'
: hasConflict
? 'bg-amber-500/10 border-amber-500/50 text-amber-400'
: 'bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer',
].join(' ')}
>
{isCapturing ? 'Press a key…' : formatBinding(draft[action], isMac)}
</button>
</div>
{hasConflict && conflict?.conflictWith.type === 'configurable' && (
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
<span className="text-amber-400">
Already used by <strong>{SHORTCUT_LABELS[conflict.conflictWith.action]}</strong>
</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={handleSwap}
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
>
Swap
</button>
<button
type="button"
onClick={handleCancelConflict}
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
);
})}
</div>
<div className="space-y-0.5">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
Configurable
</p>
{SHORTCUT_ACTIONS.map((action) => {
const isCapturing = captureFor === action;
const hasConflict = conflict?.forAction === action;
return (
<div key={action}>
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
<span className="text-sm text-slate-300">{SHORTCUT_LABELS[action]}</span>
<button
type="button"
onClick={() => {
setConflict(null);
setCaptureFor(isCapturing ? null : action);
}}
title={isCapturing ? "Press Esc to cancel" : "Click to change"}
className={[
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
isCapturing
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
: hasConflict
? "bg-amber-500/10 border-amber-500/50 text-amber-400"
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
].join(" ")}
>
{isCapturing ? "Press a key…" : formatBinding(draft[action], isMac)}
</button>
</div>
{hasConflict && conflict?.conflictWith.type === "configurable" && (
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
<span className="text-amber-400">
Already used by{" "}
<strong>{SHORTCUT_LABELS[conflict.conflictWith.action]}</strong>
</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={handleSwap}
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
>
Swap
</button>
<button
type="button"
onClick={handleCancelConflict}
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
);
})}
</div>
<div className="space-y-0.5 mt-2">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">Fixed</p>
{FIXED_SHORTCUTS.map(({ label, display }) => (
<div
key={label}
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
>
<span className="text-sm text-slate-400">{label}</span>
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
{display}
</kbd>
</div>
))}
</div>
<div className="space-y-0.5 mt-2">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
Fixed
</p>
{FIXED_SHORTCUTS.map(({ label, display }) => (
<div
key={label}
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
>
<span className="text-sm text-slate-400">{label}</span>
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
{display}
</kbd>
</div>
))}
</div>
<p className="text-[10px] text-slate-500 mt-1">
Click a shortcut then press the new key combination. Press{' '}
<span className="font-mono border border-white/10 rounded px-1">Esc</span> to cancel.
</p>
<p className="text-[10px] text-slate-500 mt-1">
Click a shortcut then press the new key combination. Press{" "}
<span className="font-mono border border-white/10 rounded px-1">Esc</span> to cancel.
</p>
<DialogFooter className="flex gap-2 sm:justify-between mt-2">
<Button
variant="ghost"
size="sm"
className="text-slate-400 hover:text-white gap-1.5"
onClick={handleReset}
>
<RotateCcw className="w-3 h-3" />
Reset to defaults
</Button>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button
size="sm"
className="bg-[#34B27B] hover:bg-[#2d9e6c] text-white"
onClick={handleSave}
>
Save
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
<DialogFooter className="flex gap-2 sm:justify-between mt-2">
<Button
variant="ghost"
size="sm"
className="text-slate-400 hover:text-white gap-1.5"
onClick={handleReset}
>
<RotateCcw className="w-3 h-3" />
Reset to defaults
</Button>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button
size="sm"
className="bg-[#34B27B] hover:bg-[#2d9e6c] text-white"
onClick={handleSave}
>
Save
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+121 -141
View File
@@ -1,145 +1,125 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ArrowRight, HelpCircle, Scissors } from "lucide-react";
import { Button } from "@/components/ui/button";
import { HelpCircle, Scissors, ArrowRight } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export function TutorialHelp() {
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1.5"
>
<HelpCircle className="w-3.5 h-3.5" />
<span className="font-medium">How trimming works</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl bg-[#09090b] border-white/10 [&>button]:text-slate-400 [&>button:hover]:text-white">
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-slate-200 flex items-center gap-2">
<Scissors className="w-5 h-5 text-[#ef4444]" /> How Trimming Works
</DialogTitle>
<DialogDescription className="text-slate-400">
Understanding how to cut out unwanted parts of your video.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-8">
{/* Explanation */}
<div className="bg-white/5 rounded-lg p-4 border border-white/5">
<p className="text-slate-300 leading-relaxed">
The Trim tool works by defining the segments you want to
<span className="text-[#ef4444] font-bold"> remove</span>. Any part
of the timeline that is
<span className="text-[#ef4444] font-bold"> covered</span> by a red
trim segment will be cut out when you export.
</p>
</div>
{/* Visual Illustration */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
Visual Example
</h3>
<div className="relative h-24 bg-[#000] rounded-lg border border-white/10 flex items-center px-4 overflow-hidden select-none">
{/* Background track (Kept parts) */}
<div className="absolute inset-x-4 h-2 bg-slate-600 rounded-full overflow-hidden">
{/* Solid line representing video */}
</div>
{/* Removed Segment 1 */}
<div
className="absolute left-[20%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
style={{ width: "20%" }}
>
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
REMOVED
</span>
</div>
{/* Removed Segment 2 */}
<div
className="absolute left-[65%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
style={{ width: "15%" }}
>
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
REMOVED
</span>
</div>
{/* Labels for kept parts */}
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">
Kept
</div>
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">
Kept
</div>
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">
Kept
</div>
</div>
<div className="flex justify-center mt-2">
<ArrowRight className="w-4 h-4 text-slate-600 rotate-90" />
</div>
{/* Result */}
<div className="relative h-12 bg-[#000] rounded-lg border border-white/10 flex items-center justify-center gap-1 px-4 select-none">
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">
Part 1
</span>
</div>
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">
Part 2
</span>
</div>
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">
Part 3
</span>
</div>
<span className="absolute right-4 text-xs text-slate-400">
Final Video
</span>
</div>
</div>
{/* Steps */}
<div className="grid grid-cols-2 gap-4">
<div className="p-3 rounded bg-white/5 border border-white/5">
<div className="text-[#ef4444] font-bold mb-1">
1. Add Trim
</div>
<p className="text-xs text-slate-400">
Press
<kbd className="bg-white/10 px-1 rounded text-slate-300">T</kbd>
or click the scissors icon to mark a section for removal.
</p>
</div>
<div className="p-3 rounded bg-white/5 border border-white/5">
<div className="text-[#ef4444] font-bold mb-1">
2. Adjust
</div>
<p className="text-xs text-slate-400">
Drag the edges of the red region to cover exactly what you want
to cut out.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1.5"
>
<HelpCircle className="w-3.5 h-3.5" />
<span className="font-medium">How trimming works</span>
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl bg-[#09090b] border-white/10 [&>button]:text-slate-400 [&>button:hover]:text-white">
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-slate-200 flex items-center gap-2">
<Scissors className="w-5 h-5 text-[#ef4444]" /> How Trimming Works
</DialogTitle>
<DialogDescription className="text-slate-400">
Understanding how to cut out unwanted parts of your video.
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-8">
{/* Explanation */}
<div className="bg-white/5 rounded-lg p-4 border border-white/5">
<p className="text-slate-300 leading-relaxed">
The Trim tool works by defining the segments you want to
<span className="text-[#ef4444] font-bold"> remove</span>. Any part of the timeline
that is
<span className="text-[#ef4444] font-bold"> covered</span> by a red trim segment will
be cut out when you export.
</p>
</div>
{/* Visual Illustration */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
Visual Example
</h3>
<div className="relative h-24 bg-[#000] rounded-lg border border-white/10 flex items-center px-4 overflow-hidden select-none">
{/* Background track (Kept parts) */}
<div className="absolute inset-x-4 h-2 bg-slate-600 rounded-full overflow-hidden">
{/* Solid line representing video */}
</div>
{/* Removed Segment 1 */}
<div
className="absolute left-[20%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
style={{ width: "20%" }}
>
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
REMOVED
</span>
</div>
{/* Removed Segment 2 */}
<div
className="absolute left-[65%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
style={{ width: "15%" }}
>
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
REMOVED
</span>
</div>
{/* Labels for kept parts */}
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">Kept</div>
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">Kept</div>
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">Kept</div>
</div>
<div className="flex justify-center mt-2">
<ArrowRight className="w-4 h-4 text-slate-600 rotate-90" />
</div>
{/* Result */}
<div className="relative h-12 bg-[#000] rounded-lg border border-white/10 flex items-center justify-center gap-1 px-4 select-none">
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">Part 1</span>
</div>
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">Part 2</span>
</div>
<div
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
style={{ width: "30%" }}
>
<span className="text-[10px] text-white font-medium">Part 3</span>
</div>
<span className="absolute right-4 text-xs text-slate-400">Final Video</span>
</div>
</div>
{/* Steps */}
<div className="grid grid-cols-2 gap-4">
<div className="p-3 rounded bg-white/5 border border-white/5">
<div className="text-[#ef4444] font-bold mb-1">1. Add Trim</div>
<p className="text-xs text-slate-400">
Press
<kbd className="bg-white/10 px-1 rounded text-slate-300">T</kbd>
or click the scissors icon to mark a section for removal.
</p>
</div>
<div className="p-3 rounded bg-white/5 border border-white/5">
<div className="text-[#ef4444] font-bold mb-1">2. Adjust</div>
<p className="text-xs text-slate-400">
Drag the edges of the red region to cover exactly what you want to cut out.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,5 +1,5 @@
export { default as VideoEditor } from './VideoEditor';
export { default as VideoPlayback } from './VideoPlayback';
export { default as PlaybackControls } from './PlaybackControls';
export { default as TimelineEditor } from './timeline/TimelineEditor';
export { default as SettingsPanel } from './SettingsPanel';
export { default as PlaybackControls } from "./PlaybackControls";
export { default as SettingsPanel } from "./SettingsPanel";
export { default as TimelineEditor } from "./timeline/TimelineEditor";
export { default as VideoEditor } from "./VideoEditor";
export { default as VideoPlayback } from "./VideoPlayback";
+255 -229
View File
@@ -1,280 +1,306 @@
import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
import {
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_CROP_REGION,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_FIGURE_DATA,
DEFAULT_ZOOM_DEPTH,
type AnnotationRegion,
type CropRegion,
type SpeedRegion,
type TrimRegion,
type ZoomRegion,
type AnnotationRegion,
type CropRegion,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_CROP_REGION,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_ZOOM_DEPTH,
type SpeedRegion,
type TrimRegion,
type ZoomRegion,
} from "./types";
const WALLPAPER_COUNT = 18;
export const WALLPAPER_PATHS = Array.from(
{ length: WALLPAPER_COUNT },
(_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
{ length: WALLPAPER_COUNT },
(_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
);
export const PROJECT_VERSION = 1;
export interface ProjectEditorState {
wallpaper: string;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled: boolean;
borderRadius: number;
padding: number;
cropRegion: CropRegion;
zoomRegions: ZoomRegion[];
trimRegions: TrimRegion[];
speedRegions: SpeedRegion[];
annotationRegions: AnnotationRegion[];
aspectRatio: AspectRatio;
exportQuality: ExportQuality;
exportFormat: ExportFormat;
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
wallpaper: string;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled: boolean;
borderRadius: number;
padding: number;
cropRegion: CropRegion;
zoomRegions: ZoomRegion[];
trimRegions: TrimRegion[];
speedRegions: SpeedRegion[];
annotationRegions: AnnotationRegion[];
aspectRatio: AspectRatio;
exportQuality: ExportQuality;
exportFormat: ExportFormat;
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
}
export interface EditorProjectData {
version: number;
videoPath: string;
editor: ProjectEditorState;
version: number;
videoPath: string;
editor: ProjectEditorState;
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
return typeof value === "number" && Number.isFinite(value);
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
return Math.min(max, Math.max(min, value));
}
export function toFileUrl(filePath: string): string {
const normalized = filePath.replace(/\\/g, "/");
if (normalized.match(/^[a-zA-Z]:/)) {
return `file:///${normalized}`;
}
return `file://${normalized}`;
const normalized = filePath.replace(/\\/g, "/");
if (normalized.match(/^[a-zA-Z]:/)) {
return `file:///${normalized}`;
}
return `file://${normalized}`;
}
export function fromFileUrl(fileUrl: string): string {
if (!fileUrl.startsWith("file://")) {
return fileUrl;
}
if (!fileUrl.startsWith("file://")) {
return fileUrl;
}
try {
const url = new URL(fileUrl);
return decodeURIComponent(url.pathname);
} catch {
return fileUrl.replace(/^file:\/\//, "");
}
try {
const url = new URL(fileUrl);
return decodeURIComponent(url.pathname);
} catch {
return fileUrl.replace(/^file:\/\//, "");
}
}
export function deriveNextId(prefix: string, ids: string[]): number {
const max = ids.reduce((acc, id) => {
const match = id.match(new RegExp(`^${prefix}-(\\d+)$`));
if (!match) return acc;
const value = Number(match[1]);
return Number.isFinite(value) ? Math.max(acc, value) : acc;
}, 0);
return max + 1;
const max = ids.reduce((acc, id) => {
const match = id.match(new RegExp(`^${prefix}-(\\d+)$`));
if (!match) return acc;
const value = Number(match[1]);
return Number.isFinite(value) ? Math.max(acc, value) : acc;
}, 0);
return max + 1;
}
export function validateProjectData(candidate: unknown): candidate is EditorProjectData {
if (!candidate || typeof candidate !== "object") return false;
const project = candidate as Partial<EditorProjectData>;
if (typeof project.version !== "number") return false;
if (typeof project.videoPath !== "string" || !project.videoPath) return false;
if (!project.editor || typeof project.editor !== "object") return false;
return true;
if (!candidate || typeof candidate !== "object") return false;
const project = candidate as Partial<EditorProjectData>;
if (typeof project.version !== "number") return false;
if (typeof project.videoPath !== "string" || !project.videoPath) return false;
if (!project.editor || typeof project.editor !== "object") return false;
return true;
}
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
? editor.zoomRegions
.filter((region): region is ZoomRegion => Boolean(region && typeof region.id === "string"))
.map((region) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
? editor.zoomRegions
.filter((region): region is ZoomRegion => Boolean(region && typeof region.id === "string"))
.map((region) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
return {
id: region.id,
startMs,
endMs,
depth: [1, 2, 3, 4, 5, 6].includes(region.depth) ? region.depth : DEFAULT_ZOOM_DEPTH,
focus: {
cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1),
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
},
};
})
: [];
return {
id: region.id,
startMs,
endMs,
depth: [1, 2, 3, 4, 5, 6].includes(region.depth) ? region.depth : DEFAULT_ZOOM_DEPTH,
focus: {
cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1),
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
},
};
})
: [];
const normalizedTrimRegions: TrimRegion[] = Array.isArray(editor.trimRegions)
? editor.trimRegions
.filter((region): region is TrimRegion => Boolean(region && typeof region.id === "string"))
.map((region) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
return {
id: region.id,
startMs,
endMs,
};
})
: [];
const normalizedTrimRegions: TrimRegion[] = Array.isArray(editor.trimRegions)
? editor.trimRegions
.filter((region): region is TrimRegion => Boolean(region && typeof region.id === "string"))
.map((region) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
return {
id: region.id,
startMs,
endMs,
};
})
: [];
const normalizedSpeedRegions: SpeedRegion[] = Array.isArray(editor.speedRegions)
? editor.speedRegions
.filter((region): region is SpeedRegion => Boolean(region && typeof region.id === "string"))
.map((region) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
const normalizedSpeedRegions: SpeedRegion[] = Array.isArray(editor.speedRegions)
? editor.speedRegions
.filter((region): region is SpeedRegion => Boolean(region && typeof region.id === "string"))
.map((region) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
const speed =
region.speed === 0.25 ||
region.speed === 0.5 ||
region.speed === 0.75 ||
region.speed === 1.25 ||
region.speed === 1.5 ||
region.speed === 1.75 ||
region.speed === 2
? region.speed
: DEFAULT_PLAYBACK_SPEED;
const speed =
region.speed === 0.25 ||
region.speed === 0.5 ||
region.speed === 0.75 ||
region.speed === 1.25 ||
region.speed === 1.5 ||
region.speed === 1.75 ||
region.speed === 2
? region.speed
: DEFAULT_PLAYBACK_SPEED;
return {
id: region.id,
startMs,
endMs,
speed,
};
})
: [];
return {
id: region.id,
startMs,
endMs,
speed,
};
})
: [];
const normalizedAnnotationRegions: AnnotationRegion[] = Array.isArray(editor.annotationRegions)
? editor.annotationRegions
.filter((region): region is AnnotationRegion => Boolean(region && typeof region.id === "string"))
.map((region, index) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
const normalizedAnnotationRegions: AnnotationRegion[] = Array.isArray(editor.annotationRegions)
? editor.annotationRegions
.filter((region): region is AnnotationRegion =>
Boolean(region && typeof region.id === "string"),
)
.map((region, index) => {
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
return {
id: region.id,
startMs,
endMs,
type: region.type === "image" || region.type === "figure" ? region.type : "text",
content: typeof region.content === "string" ? region.content : "",
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
position: {
x: clamp(
isFiniteNumber(region.position?.x) ? region.position.x : DEFAULT_ANNOTATION_POSITION.x,
0,
100,
),
y: clamp(
isFiniteNumber(region.position?.y) ? region.position.y : DEFAULT_ANNOTATION_POSITION.y,
0,
100,
),
},
size: {
width: clamp(
isFiniteNumber(region.size?.width) ? region.size.width : DEFAULT_ANNOTATION_SIZE.width,
1,
200,
),
height: clamp(
isFiniteNumber(region.size?.height) ? region.size.height : DEFAULT_ANNOTATION_SIZE.height,
1,
200,
),
},
style: {
...DEFAULT_ANNOTATION_STYLE,
...(region.style && typeof region.style === "object" ? region.style : {}),
},
zIndex: isFiniteNumber(region.zIndex) ? region.zIndex : index + 1,
figureData: region.figureData
? {
...DEFAULT_FIGURE_DATA,
...region.figureData,
}
: undefined,
};
})
: [];
return {
id: region.id,
startMs,
endMs,
type: region.type === "image" || region.type === "figure" ? region.type : "text",
content: typeof region.content === "string" ? region.content : "",
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
position: {
x: clamp(
isFiniteNumber(region.position?.x)
? region.position.x
: DEFAULT_ANNOTATION_POSITION.x,
0,
100,
),
y: clamp(
isFiniteNumber(region.position?.y)
? region.position.y
: DEFAULT_ANNOTATION_POSITION.y,
0,
100,
),
},
size: {
width: clamp(
isFiniteNumber(region.size?.width)
? region.size.width
: DEFAULT_ANNOTATION_SIZE.width,
1,
200,
),
height: clamp(
isFiniteNumber(region.size?.height)
? region.size.height
: DEFAULT_ANNOTATION_SIZE.height,
1,
200,
),
},
style: {
...DEFAULT_ANNOTATION_STYLE,
...(region.style && typeof region.style === "object" ? region.style : {}),
},
zIndex: isFiniteNumber(region.zIndex) ? region.zIndex : index + 1,
figureData: region.figureData
? {
...DEFAULT_FIGURE_DATA,
...region.figureData,
}
: undefined,
};
})
: [];
const rawCropX = isFiniteNumber(editor.cropRegion?.x) ? editor.cropRegion.x : DEFAULT_CROP_REGION.x;
const rawCropY = isFiniteNumber(editor.cropRegion?.y) ? editor.cropRegion.y : DEFAULT_CROP_REGION.y;
const rawCropWidth = isFiniteNumber(editor.cropRegion?.width) ? editor.cropRegion.width : DEFAULT_CROP_REGION.width;
const rawCropHeight = isFiniteNumber(editor.cropRegion?.height)
? editor.cropRegion.height
: DEFAULT_CROP_REGION.height;
const rawCropX = isFiniteNumber(editor.cropRegion?.x)
? editor.cropRegion.x
: DEFAULT_CROP_REGION.x;
const rawCropY = isFiniteNumber(editor.cropRegion?.y)
? editor.cropRegion.y
: DEFAULT_CROP_REGION.y;
const rawCropWidth = isFiniteNumber(editor.cropRegion?.width)
? editor.cropRegion.width
: DEFAULT_CROP_REGION.width;
const rawCropHeight = isFiniteNumber(editor.cropRegion?.height)
? editor.cropRegion.height
: DEFAULT_CROP_REGION.height;
const cropX = clamp(rawCropX, 0, 1);
const cropY = clamp(rawCropY, 0, 1);
const cropWidth = clamp(rawCropWidth, 0.01, 1 - cropX);
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
const cropX = clamp(rawCropX, 0, 1);
const cropY = clamp(rawCropY, 0, 1);
const cropWidth = clamp(rawCropWidth, 0.01, 1 - cropX);
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
return {
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0],
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
motionBlurEnabled: typeof editor.motionBlurEnabled === "boolean" ? editor.motionBlurEnabled : false,
borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 0,
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50,
cropRegion: {
x: cropX,
y: cropY,
width: cropWidth,
height: cropHeight,
},
zoomRegions: normalizedZoomRegions,
trimRegions: normalizedTrimRegions,
speedRegions: normalizedSpeedRegions,
annotationRegions: normalizedAnnotationRegions,
aspectRatio: editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
exportQuality: editor.exportQuality === "medium" || editor.exportQuality === "source" ? editor.exportQuality : "good",
exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4",
gifFrameRate:
editor.gifFrameRate === 15 ||
editor.gifFrameRate === 20 ||
editor.gifFrameRate === 25 ||
editor.gifFrameRate === 30
? editor.gifFrameRate
: 15,
gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true,
gifSizePreset:
editor.gifSizePreset === "medium" || editor.gifSizePreset === "large" || editor.gifSizePreset === "original"
? editor.gifSizePreset
: "medium",
};
return {
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0],
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
motionBlurEnabled:
typeof editor.motionBlurEnabled === "boolean" ? editor.motionBlurEnabled : false,
borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 0,
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50,
cropRegion: {
x: cropX,
y: cropY,
width: cropWidth,
height: cropHeight,
},
zoomRegions: normalizedZoomRegions,
trimRegions: normalizedTrimRegions,
speedRegions: normalizedSpeedRegions,
annotationRegions: normalizedAnnotationRegions,
aspectRatio:
editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
exportQuality:
editor.exportQuality === "medium" || editor.exportQuality === "source"
? editor.exportQuality
: "good",
exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4",
gifFrameRate:
editor.gifFrameRate === 15 ||
editor.gifFrameRate === 20 ||
editor.gifFrameRate === 25 ||
editor.gifFrameRate === 30
? editor.gifFrameRate
: 15,
gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true,
gifSizePreset:
editor.gifSizePreset === "medium" ||
editor.gifSizePreset === "large" ||
editor.gifSizePreset === "original"
? editor.gifSizePreset
: "medium",
};
}
export function createProjectData(videoPath: string, editor: ProjectEditorState): EditorProjectData {
return {
version: PROJECT_VERSION,
videoPath,
editor,
};
export function createProjectData(
videoPath: string,
editor: ProjectEditorState,
): EditorProjectData {
return {
version: PROJECT_VERSION,
videoPath,
editor,
};
}
+149 -143
View File
@@ -1,165 +1,171 @@
import { useMemo } from "react";
import { useItem } from "dnd-timeline";
import type { Span } from "dnd-timeline";
import { useItem } from "dnd-timeline";
import { Gauge, MessageSquare, Scissors, ZoomIn } from "lucide-react";
import { useMemo } from "react";
import { cn } from "@/lib/utils";
import { ZoomIn, Scissors, MessageSquare, Gauge } from "lucide-react";
import glassStyles from "./ItemGlass.module.css";
interface ItemProps {
id: string;
span: Span;
rowId: string;
children: React.ReactNode;
isSelected?: boolean;
onSelect?: () => void;
zoomDepth?: number;
speedValue?: number;
variant?: 'zoom' | 'trim' | 'annotation' | 'speed';
id: string;
span: Span;
rowId: string;
children: React.ReactNode;
isSelected?: boolean;
onSelect?: () => void;
zoomDepth?: number;
speedValue?: number;
variant?: "zoom" | "trim" | "annotation" | "speed";
}
// Map zoom depth to multiplier labels
const ZOOM_LABELS: Record<number, string> = {
1: "1.25×",
2: "1.5×",
3: "1.8×",
4: "2.2×",
5: "3.5×",
6: "5×",
1: "1.25×",
2: "1.5×",
3: "1.8×",
4: "2.2×",
5: "3.5×",
6: "5×",
};
function formatMs(ms: number): string {
const totalSeconds = ms / 1000;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes > 0) {
return `${minutes}:${seconds.toFixed(1).padStart(4, '0')}`;
}
return `${seconds.toFixed(1)}s`;
const totalSeconds = ms / 1000;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes > 0) {
return `${minutes}:${seconds.toFixed(1).padStart(4, "0")}`;
}
return `${seconds.toFixed(1)}s`;
}
export default function Item({
id,
span,
rowId,
isSelected = false,
onSelect,
zoomDepth = 1,
speedValue,
variant = 'zoom',
children
id,
span,
rowId,
isSelected = false,
onSelect,
zoomDepth = 1,
speedValue,
variant = "zoom",
children,
}: ItemProps) {
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
span,
data: { rowId },
});
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
span,
data: { rowId },
});
const isZoom = variant === 'zoom';
const isTrim = variant === 'trim';
const isSpeed = variant === 'speed';
const isZoom = variant === "zoom";
const isTrim = variant === "trim";
const isSpeed = variant === "speed";
const glassClass = isZoom
? glassStyles.glassGreen
: isTrim
? glassStyles.glassRed
: isSpeed
? glassStyles.glassAmber
: glassStyles.glassYellow;
const glassClass = isZoom
? glassStyles.glassGreen
: isTrim
? glassStyles.glassRed
: isSpeed
? glassStyles.glassAmber
: glassStyles.glassYellow;
const endCapColor = isZoom
? '#21916A'
: isTrim
? '#ef4444'
: isSpeed
? '#d97706'
: '#B4A046';
const endCapColor = isZoom ? "#21916A" : isTrim ? "#ef4444" : isSpeed ? "#d97706" : "#B4A046";
const timeLabel = useMemo(
() => `${formatMs(span.start)} ${formatMs(span.end)}`,
[span.start, span.end],
);
const timeLabel = useMemo(
() => `${formatMs(span.start)} ${formatMs(span.end)}`,
[span.start, span.end],
);
// Minimum clickable width on the outer wrapper.
// Kept small (6px) so items visually distinguish their real positions;
// users should zoom in to interact with sub-second items precisely.
const MIN_ITEM_PX = 6;
const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX };
// Minimum clickable width on the outer wrapper.
// Kept small (6px) so items visually distinguish their real positions;
// users should zoom in to interact with sub-second items precisely.
const MIN_ITEM_PX = 6;
const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX };
return (
<div
ref={setNodeRef}
style={safeItemStyle}
{...listeners}
{...attributes}
onPointerDownCapture={() => onSelect?.()}
className="group"
>
<div style={{ ...itemContentStyle, minWidth: 24 }}>
<div
className={cn(
glassClass,
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
isSelected && glassStyles.selected
)}
style={{ height: 40, color: '#fff', minWidth: 24 }}
onClick={(event) => {
event.stopPropagation();
onSelect?.();
}}
>
<div
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: endCapColor }}
title="Resize left"
/>
<div
className={cn(glassStyles.zoomEndCap, glassStyles.right)}
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: endCapColor }}
title="Resize right"
/>
{/* Content */}
<div className="relative z-10 flex flex-col items-center justify-center text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none overflow-hidden">
<div className="flex items-center gap-1.5">
{isZoom ? (
<>
<ZoomIn className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
</>
) : isTrim ? (
<>
<Scissors className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
Trim
</span>
</>
) : isSpeed ? (
<>
<Gauge className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{speedValue !== undefined ? `${speedValue}×` : 'Speed'}
</span>
</>
) : (
<>
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{children}
</span>
</>
)}
</div>
<span
className={`text-[9px] tabular-nums tracking-tight whitespace-nowrap transition-opacity ${
isSelected ? 'opacity-60' : 'opacity-0 group-hover:opacity-40'
}`}
>
{timeLabel}
</span>
</div>
</div>
</div>
</div>
);
}
return (
<div
ref={setNodeRef}
style={safeItemStyle}
{...listeners}
{...attributes}
onPointerDownCapture={() => onSelect?.()}
className="group"
>
<div style={{ ...itemContentStyle, minWidth: 24 }}>
<div
className={cn(
glassClass,
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
isSelected && glassStyles.selected,
)}
style={{ height: 40, color: "#fff", minWidth: 24 }}
onClick={(event) => {
event.stopPropagation();
onSelect?.();
}}
>
<div
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
style={{
cursor: "col-resize",
pointerEvents: "auto",
width: 8,
opacity: 0.9,
background: endCapColor,
}}
title="Resize left"
/>
<div
className={cn(glassStyles.zoomEndCap, glassStyles.right)}
style={{
cursor: "col-resize",
pointerEvents: "auto",
width: 8,
opacity: 0.9,
background: endCapColor,
}}
title="Resize right"
/>
{/* Content */}
<div className="relative z-10 flex flex-col items-center justify-center text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none overflow-hidden">
<div className="flex items-center gap-1.5">
{isZoom ? (
<>
<ZoomIn className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
</>
) : isTrim ? (
<>
<Scissors className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
Trim
</span>
</>
) : isSpeed ? (
<>
<Gauge className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{speedValue !== undefined ? `${speedValue}×` : "Speed"}
</span>
</>
) : (
<>
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
{children}
</span>
</>
)}
</div>
<span
className={`text-[9px] tabular-nums tracking-tight whitespace-nowrap transition-opacity ${
isSelected ? "opacity-60" : "opacity-0 group-hover:opacity-40"
}`}
>
{timeLabel}
</span>
</div>
</div>
</div>
</div>
);
}
@@ -1,116 +1,126 @@
.glassGreen {
position: relative;
border-radius: 8px;
-corner-smoothing: antialiased;
background: rgba(52, 178, 123, 0.15);
border: 1px solid rgba(52, 178, 123, 0.3);
box-shadow: 0 2px 12px 0 rgba(52, 178, 123, 0.1) inset;
margin: 2px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
border-radius: 8px;
-corner-smoothing: antialiased;
background: rgba(52, 178, 123, 0.15);
border: 1px solid rgba(52, 178, 123, 0.3);
box-shadow: 0 2px 12px 0 rgba(52, 178, 123, 0.1) inset;
margin: 2px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.glassGreen:hover {
background: rgba(52, 178, 123, 0.25);
border-color: rgba(52, 178, 123, 0.5);
box-shadow: 0 4px 20px 0 rgba(52, 178, 123, 0.2) inset;
background: rgba(52, 178, 123, 0.25);
border-color: rgba(52, 178, 123, 0.5);
box-shadow: 0 4px 20px 0 rgba(52, 178, 123, 0.2) inset;
}
.glassGreen.selected {
background: rgba(52, 178, 123, 0.35);
border-color: #34B27B;
box-shadow: 0 0 0 1px #34B27B, 0 4px 20px 0 rgba(52, 178, 123, 0.3) inset;
z-index: 10;
background: rgba(52, 178, 123, 0.35);
border-color: #34b27b;
box-shadow:
0 0 0 1px #34b27b,
0 4px 20px 0 rgba(52, 178, 123, 0.3) inset;
z-index: 10;
}
.glassRed {
position: relative;
border-radius: 8px;
-corner-smoothing: antialiased;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
box-shadow: 0 2px 12px 0 rgba(239, 68, 68, 0.1) inset;
margin: 2px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
border-radius: 8px;
-corner-smoothing: antialiased;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
box-shadow: 0 2px 12px 0 rgba(239, 68, 68, 0.1) inset;
margin: 2px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.glassRed:hover {
background: rgba(239, 68, 68, 0.25);
border-color: rgba(239, 68, 68, 0.5);
box-shadow: 0 4px 20px 0 rgba(239, 68, 68, 0.2) inset;
background: rgba(239, 68, 68, 0.25);
border-color: rgba(239, 68, 68, 0.5);
box-shadow: 0 4px 20px 0 rgba(239, 68, 68, 0.2) inset;
}
.glassRed.selected {
background: rgba(239, 68, 68, 0.35);
border-color: #ef4444;
box-shadow: 0 0 0 1px #ef4444, 0 4px 20px 0 rgba(239, 68, 68, 0.3) inset;
z-index: 10;
background: rgba(239, 68, 68, 0.35);
border-color: #ef4444;
box-shadow:
0 0 0 1px #ef4444,
0 4px 20px 0 rgba(239, 68, 68, 0.3) inset;
z-index: 10;
}
.glassYellow {
position: relative;
border-radius: 8px;
-corner-smoothing: antialiased;
background: rgba(180, 160, 70, 0.15);
border: 1px solid rgba(180, 160, 70, 0.3);
box-shadow: 0 2px 12px 0 rgba(180, 160, 70, 0.1) inset;
margin: 2px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
border-radius: 8px;
-corner-smoothing: antialiased;
background: rgba(180, 160, 70, 0.15);
border: 1px solid rgba(180, 160, 70, 0.3);
box-shadow: 0 2px 12px 0 rgba(180, 160, 70, 0.1) inset;
margin: 2px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.glassYellow:hover {
background: rgba(180, 160, 70, 0.25);
border-color: rgba(180, 160, 70, 0.5);
box-shadow: 0 4px 20px 0 rgba(180, 160, 70, 0.2) inset;
background: rgba(180, 160, 70, 0.25);
border-color: rgba(180, 160, 70, 0.5);
box-shadow: 0 4px 20px 0 rgba(180, 160, 70, 0.2) inset;
}
.glassYellow.selected {
background: rgba(180, 160, 70, 0.35);
border-color: #B4A046;
box-shadow: 0 0 0 1px #B4A046, 0 4px 20px 0 rgba(180, 160, 70, 0.3) inset;
z-index: 10;
background: rgba(180, 160, 70, 0.35);
border-color: #b4a046;
box-shadow:
0 0 0 1px #b4a046,
0 4px 20px 0 rgba(180, 160, 70, 0.3) inset;
z-index: 10;
}
.glassAmber {
position: relative;
border-radius: 8px;
-corner-smoothing: antialiased;
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
box-shadow: 0 2px 12px 0 rgba(245, 158, 11, 0.1) inset;
margin: 2px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
border-radius: 8px;
-corner-smoothing: antialiased;
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
box-shadow: 0 2px 12px 0 rgba(245, 158, 11, 0.1) inset;
margin: 2px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.glassAmber:hover {
background: rgba(245, 158, 11, 0.25);
border-color: rgba(245, 158, 11, 0.5);
box-shadow: 0 4px 20px 0 rgba(245, 158, 11, 0.2) inset;
background: rgba(245, 158, 11, 0.25);
border-color: rgba(245, 158, 11, 0.5);
box-shadow: 0 4px 20px 0 rgba(245, 158, 11, 0.2) inset;
}
.glassAmber.selected {
background: rgba(245, 158, 11, 0.35);
border-color: #f59e0b;
box-shadow: 0 0 0 1px #f59e0b, 0 4px 20px 0 rgba(245, 158, 11, 0.3) inset;
z-index: 10;
background: rgba(245, 158, 11, 0.35);
border-color: #f59e0b;
box-shadow:
0 0 0 1px #f59e0b,
0 4px 20px 0 rgba(245, 158, 11, 0.3) inset;
z-index: 10;
}
.zoomEndCap {
position: absolute;
top: 0;
bottom: 0;
width: 4px;
pointer-events: none;
z-index: 2;
opacity: 0.45;
transition: opacity 0.2s, width 0.2s;
position: absolute;
top: 0;
bottom: 0;
width: 4px;
pointer-events: none;
z-index: 2;
opacity: 0.45;
transition:
opacity 0.2s,
width 0.2s;
}
.glassGreen:hover .zoomEndCap,
@@ -121,19 +131,19 @@
.glassYellow.selected .zoomEndCap,
.glassAmber:hover .zoomEndCap,
.glassAmber.selected .zoomEndCap {
opacity: 1;
opacity: 1;
}
.zoomEndCap.left {
left: 0;
cursor: ew-resize;
border-top-left-radius: 7px;
border-bottom-left-radius: 7px;
left: 0;
cursor: ew-resize;
border-top-left-radius: 7px;
border-bottom-left-radius: 7px;
}
.zoomEndCap.right {
right: 0;
cursor: ew-resize;
border-top-right-radius: 7px;
border-bottom-right-radius: 7px;
}
right: 0;
cursor: ew-resize;
border-top-right-radius: 7px;
border-bottom-right-radius: 7px;
}
@@ -1,104 +1,114 @@
import React, { useState, useEffect } from "react";
import { useTimelineContext } from "dnd-timeline";
import React, { useEffect, useState } from "react";
interface Keyframe {
id: string;
time: number;
id: string;
time: number;
}
interface KeyframeMarkersProps {
keyframes: Keyframe[];
selectedKeyframeId: string | null;
setSelectedKeyframeId: (id: string | null) => void;
onKeyframeMove: (id: string, newTime: number) => void;
videoDurationMs: number;
timelineRef: React.RefObject<HTMLDivElement>;
keyframes: Keyframe[];
selectedKeyframeId: string | null;
setSelectedKeyframeId: (id: string | null) => void;
onKeyframeMove: (id: string, newTime: number) => void;
videoDurationMs: number;
timelineRef: React.RefObject<HTMLDivElement>;
}
const KeyframeMarkers: React.FC<KeyframeMarkersProps> = ({
keyframes,
selectedKeyframeId,
setSelectedKeyframeId,
onKeyframeMove,
videoDurationMs,
timelineRef
keyframes,
selectedKeyframeId,
setSelectedKeyframeId,
onKeyframeMove,
videoDurationMs,
timelineRef,
}) => {
const { sidebarWidth, range, valueToPixels, pixelsToValue } = useTimelineContext();
const [draggingKeyframeId, setDraggingKeyframeId] = useState<string | null>(null);
const { sidebarWidth, range, valueToPixels, pixelsToValue } = useTimelineContext();
const [draggingKeyframeId, setDraggingKeyframeId] = useState<string | null>(null);
useEffect(() => {
if (!draggingKeyframeId) return;
useEffect(() => {
if (!draggingKeyframeId) return;
const handleMouseMove = (e: MouseEvent) => {
if (!timelineRef.current) return;
const handleMouseMove = (e: MouseEvent) => {
if (!timelineRef.current) return;
const rect = timelineRef.current.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
const relativeMs = pixelsToValue(clickX);
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
const rect = timelineRef.current.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
const relativeMs = pixelsToValue(clickX);
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
// Update the keyframe position in real-time
onKeyframeMove(draggingKeyframeId, absoluteMs);
};
// Update the keyframe position in real-time
onKeyframeMove(draggingKeyframeId, absoluteMs);
};
const handleMouseUp = () => {
setDraggingKeyframeId(null);
document.body.style.cursor = '';
};
const handleMouseUp = () => {
setDraggingKeyframeId(null);
document.body.style.cursor = "";
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'ew-resize';
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "ew-resize";
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
};
}, [draggingKeyframeId, onKeyframeMove, timelineRef, sidebarWidth, range.start, videoDurationMs, pixelsToValue]);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
};
}, [
draggingKeyframeId,
onKeyframeMove,
timelineRef,
sidebarWidth,
range.start,
videoDurationMs,
pixelsToValue,
]);
return (
<>
{keyframes.map(kf => {
const offset = valueToPixels(kf.time - range.start);
const isSelected = kf.id === selectedKeyframeId;
const isDragging = kf.id === draggingKeyframeId;
return (
<>
{keyframes.map((kf) => {
const offset = valueToPixels(kf.time - range.start);
const isSelected = kf.id === selectedKeyframeId;
const isDragging = kf.id === draggingKeyframeId;
return (
<div
key={kf.id}
className={`absolute top-8 cursor-grab active:cursor-grabbing ${isSelected ? 'ring-2 ring-[#34B27B]' : ''}`}
style={{
left: `${sidebarWidth + offset - 8}px`,
zIndex: isDragging ? 50 : 40,
transition: isDragging ? 'none' : 'left 0.1s ease-out'
}}
onMouseDown={e => {
e.stopPropagation();
setSelectedKeyframeId(kf.id);
setDraggingKeyframeId(kf.id);
}}
onContextMenu={e => {
e.preventDefault();
e.stopPropagation();
setSelectedKeyframeId(kf.id);
}}
title={`Keyframe @ ${Math.round(kf.time)}ms (drag to move, Delete/Backspace to remove)`}
>
<div style={{
width: '10px',
height: '10px',
background: '#ffe100ff',
transform: 'rotate(45deg)',
border: 'none',
opacity: isSelected ? 1 : 0.6,
transition: 'opacity 0.15s',
}} />
</div>
);
})}
</>
);
return (
<div
key={kf.id}
className={`absolute top-8 cursor-grab active:cursor-grabbing ${isSelected ? "ring-2 ring-[#34B27B]" : ""}`}
style={{
left: `${sidebarWidth + offset - 8}px`,
zIndex: isDragging ? 50 : 40,
transition: isDragging ? "none" : "left 0.1s ease-out",
}}
onMouseDown={(e) => {
e.stopPropagation();
setSelectedKeyframeId(kf.id);
setDraggingKeyframeId(kf.id);
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setSelectedKeyframeId(kf.id);
}}
title={`Keyframe @ ${Math.round(kf.time)}ms (drag to move, Delete/Backspace to remove)`}
>
<div
style={{
width: "10px",
height: "10px",
background: "#ffe100ff",
transform: "rotate(45deg)",
border: "none",
opacity: isSelected ? 1 : 0.6,
transition: "opacity 0.15s",
}}
/>
</div>
);
})}
</>
);
};
export default KeyframeMarkers;
+31 -31
View File
@@ -1,38 +1,38 @@
import { useRow } from "dnd-timeline";
import type { RowDefinition } from "dnd-timeline";
import { useRow } from "dnd-timeline";
interface RowProps extends RowDefinition {
children: React.ReactNode;
label?: string;
hint?: string;
isEmpty?: boolean;
labelColor?: string;
children: React.ReactNode;
label?: string;
hint?: string;
isEmpty?: boolean;
labelColor?: string;
}
export default function Row({ id, children, label, hint, isEmpty, labelColor = '#666' }: RowProps) {
const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id });
export default function Row({ id, children, label, hint, isEmpty, labelColor = "#666" }: RowProps) {
const { setNodeRef, rowWrapperStyle, rowStyle } = useRow({ id });
return (
<div
className="border-b border-[#18181b] bg-[#18181b] relative"
style={{ ...rowWrapperStyle, minHeight: 48, marginBottom: 4 }}
>
{label && (
<div
className="absolute left-1.5 top-1/2 -translate-y-1/2 text-[9px] font-semibold uppercase tracking-widest z-20 pointer-events-none select-none"
style={{ color: labelColor, writingMode: 'horizontal-tb' }}
>
{label}
</div>
)}
{isEmpty && hint && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-10">
<span className="text-[11px] text-white/15 font-medium">{hint}</span>
</div>
)}
<div ref={setNodeRef} style={rowStyle}>
{children}
</div>
</div>
);
return (
<div
className="border-b border-[#18181b] bg-[#18181b] relative"
style={{ ...rowWrapperStyle, minHeight: 48, marginBottom: 4 }}
>
{label && (
<div
className="absolute left-1.5 top-1/2 -translate-y-1/2 text-[9px] font-semibold uppercase tracking-widest z-20 pointer-events-none select-none"
style={{ color: labelColor, writingMode: "horizontal-tb" }}
>
{label}
</div>
)}
{isEmpty && hint && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none z-10">
<span className="text-[11px] text-white/15 font-medium">{hint}</span>
</div>
)}
<div ref={setNodeRef} style={rowStyle}>
{children}
</div>
</div>
);
}
@@ -1,12 +1,17 @@
import { cn } from "@/lib/utils";
interface SubrowProps {
children: React.ReactNode;
children: React.ReactNode;
}
export default function Subrow({ children }: SubrowProps) {
return (
<div className={cn("flex items-center min-h-[32px] gap-1 px-2 py-0.5 bg-[#23232a] rounded-md text-slate-300")}>
{children}
</div>
);
}
return (
<div
className={cn(
"flex items-center min-h-[32px] gap-1 px-2 py-0.5 bg-[#23232a] rounded-md text-slate-300",
)}
>
{children}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,311 +1,317 @@
import { useCallback, useRef } from "react";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { TimelineContext } from "dnd-timeline";
import type {
DragEndEvent,
DragMoveEvent,
DragStartEvent,
Range,
ResizeEndEvent,
ResizeMoveEvent,
Span,
DragEndEvent,
DragMoveEvent,
DragStartEvent,
Range,
ResizeEndEvent,
ResizeMoveEvent,
Span,
} from "dnd-timeline";
import { TimelineContext } from "dnd-timeline";
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { useCallback, useRef } from "react";
interface TimelineWrapperProps {
children: ReactNode;
range: Range;
videoDuration: number;
hasOverlap: (newSpan: Span, excludeId?: string) => boolean;
onRangeChange: Dispatch<SetStateAction<Range>>;
minItemDurationMs: number;
minVisibleRangeMs: number;
gridSizeMs?: number;
onItemSpanChange: (id: string, span: Span) => void;
allRegionSpans?: { id: string; start: number; end: number }[];
children: ReactNode;
range: Range;
videoDuration: number;
hasOverlap: (newSpan: Span, excludeId?: string) => boolean;
onRangeChange: Dispatch<SetStateAction<Range>>;
minItemDurationMs: number;
minVisibleRangeMs: number;
gridSizeMs?: number;
onItemSpanChange: (id: string, span: Span) => void;
allRegionSpans?: { id: string; start: number; end: number }[];
}
export default function TimelineWrapper({
children,
range,
videoDuration,
hasOverlap,
onRangeChange,
minItemDurationMs,
minVisibleRangeMs,
gridSizeMs: _gridSizeMs,
onItemSpanChange,
allRegionSpans = [],
children,
range,
videoDuration,
hasOverlap,
onRangeChange,
minItemDurationMs,
minVisibleRangeMs,
gridSizeMs: _gridSizeMs,
onItemSpanChange,
allRegionSpans = [],
}: TimelineWrapperProps) {
const totalMs = Math.max(0, Math.round(videoDuration * 1000));
const totalMs = Math.max(0, Math.round(videoDuration * 1000));
const clampSpanToBounds = useCallback(
(span: Span): Span => {
const rawDuration = Math.max(span.end - span.start, 0);
const normalizedStart = Number.isFinite(span.start) ? span.start : 0;
const clampSpanToBounds = useCallback(
(span: Span): Span => {
const rawDuration = Math.max(span.end - span.start, 0);
const normalizedStart = Number.isFinite(span.start) ? span.start : 0;
if (totalMs === 0) {
const minDuration = Math.max(minItemDurationMs, 1);
const duration = Math.max(rawDuration, minDuration);
const start = Math.max(0, normalizedStart);
return {
start,
end: start + duration,
};
}
if (totalMs === 0) {
const minDuration = Math.max(minItemDurationMs, 1);
const duration = Math.max(rawDuration, minDuration);
const start = Math.max(0, normalizedStart);
return {
start,
end: start + duration,
};
}
const minDuration = Math.min(Math.max(minItemDurationMs, 1), totalMs);
const duration = Math.min(Math.max(rawDuration, minDuration), totalMs);
const minDuration = Math.min(Math.max(minItemDurationMs, 1), totalMs);
const duration = Math.min(Math.max(rawDuration, minDuration), totalMs);
const start = Math.max(0, Math.min(normalizedStart, totalMs - duration));
const end = start + duration;
const start = Math.max(0, Math.min(normalizedStart, totalMs - duration));
const end = start + duration;
return { start, end };
},
[minItemDurationMs, totalMs],
);
return { start, end };
},
[minItemDurationMs, totalMs],
);
const clampRange = useCallback(
(candidate: Range): Range => {
if (totalMs === 0) {
const minSpan = Math.max(minVisibleRangeMs, 1);
const span = Math.max(candidate.end - candidate.start, minSpan);
const start = Math.max(0, Math.min(candidate.start, candidate.end - span));
return { start, end: start + span };
}
const clampRange = useCallback(
(candidate: Range): Range => {
if (totalMs === 0) {
const minSpan = Math.max(minVisibleRangeMs, 1);
const span = Math.max(candidate.end - candidate.start, minSpan);
const start = Math.max(0, Math.min(candidate.start, candidate.end - span));
return { start, end: start + span };
}
const rawStart = Math.max(0, candidate.start);
const rawEnd = candidate.end;
const clampedEnd = Math.min(rawEnd, totalMs);
const minSpan = Math.min(Math.max(minVisibleRangeMs, 1), totalMs);
const desiredSpan = clampedEnd - rawStart;
const span = Math.min(Math.max(desiredSpan, minSpan), totalMs);
let finalStart = rawStart;
let finalEnd = finalStart + span;
if (finalEnd > totalMs) {
finalEnd = totalMs;
finalStart = Math.max(0, finalEnd - span);
}
const rawStart = Math.max(0, candidate.start);
const rawEnd = candidate.end;
const clampedEnd = Math.min(rawEnd, totalMs);
return { start: finalStart, end: finalEnd };
},
[minVisibleRangeMs, totalMs],
);
const minSpan = Math.min(Math.max(minVisibleRangeMs, 1), totalMs);
const desiredSpan = clampedEnd - rawStart;
const span = Math.min(Math.max(desiredSpan, minSpan), totalMs);
// When a span overlaps neighbours, clamp it to the nearest boundary
const clampToNeighbours = useCallback(
(span: Span, activeItemId: string): Span => {
const siblings = allRegionSpans.filter((r) => r.id !== activeItemId);
let { start, end } = span;
let finalStart = rawStart;
let finalEnd = finalStart + span;
for (const r of siblings) {
// Span's right edge crossed into a region to the right
if (end > r.start && start < r.start) {
end = r.start;
}
// Span's left edge crossed into a region to the left
if (start < r.end && end > r.end) {
start = r.end;
}
}
if (finalEnd > totalMs) {
finalEnd = totalMs;
finalStart = Math.max(0, finalEnd - span);
}
// Ensure minimum duration after clamping
const minDur = Math.min(minItemDurationMs, totalMs || minItemDurationMs);
if (end - start < minDur) {
// Try extending in the direction that has room
if (end + minDur - (end - start) <= totalMs) {
end = start + minDur;
} else {
start = end - minDur;
}
}
return { start: finalStart, end: finalEnd };
},
[minVisibleRangeMs, totalMs],
);
return { start: Math.max(0, start), end: Math.min(end, totalMs || end) };
},
[allRegionSpans, minItemDurationMs, totalMs],
);
// When a span overlaps neighbours, clamp it to the nearest boundary
const clampToNeighbours = useCallback(
(span: Span, activeItemId: string): Span => {
const siblings = allRegionSpans.filter((r) => r.id !== activeItemId);
let { start, end } = span;
const onResizeEnd = useCallback(
(event: ResizeEndEvent) => {
const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event);
if (!updatedSpan) return;
for (const r of siblings) {
// Span's right edge crossed into a region to the right
if (end > r.start && start < r.start) {
end = r.start;
}
// Span's left edge crossed into a region to the left
if (start < r.end && end > r.end) {
start = r.end;
}
}
const activeItemId = event.active.id as string;
let clampedSpan = clampSpanToBounds(updatedSpan);
// Ensure minimum duration after clamping
const minDur = Math.min(minItemDurationMs, totalMs || minItemDurationMs);
if (end - start < minDur) {
// Try extending in the direction that has room
if (end + minDur - (end - start) <= totalMs) {
end = start + minDur;
} else {
start = end - minDur;
}
}
const effectiveMinDuration = totalMs > 0
? Math.min(minItemDurationMs, totalMs)
: minItemDurationMs;
if (clampedSpan.end - clampedSpan.start < effectiveMinDuration) {
return;
}
return { start: Math.max(0, start), end: Math.min(end, totalMs || end) };
},
[allRegionSpans, minItemDurationMs, totalMs],
);
// Clamp to neighbour boundaries instead of rejecting
if (hasOverlap(clampedSpan, activeItemId)) {
clampedSpan = clampToNeighbours(clampedSpan, activeItemId);
// If still overlapping after clamping, fall back to original position
if (hasOverlap(clampedSpan, activeItemId)) {
return;
}
}
const onResizeEnd = useCallback(
(event: ResizeEndEvent) => {
const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event);
if (!updatedSpan) return;
onItemSpanChange(activeItemId, clampedSpan);
},
[clampSpanToBounds, clampToNeighbours, hasOverlap, minItemDurationMs, onItemSpanChange, totalMs]
);
const activeItemId = event.active.id as string;
let clampedSpan = clampSpanToBounds(updatedSpan);
const onDragEnd = useCallback(
(event: DragEndEvent) => {
const activeRowId = event.over?.id as string;
const updatedSpan = event.active.data.current.getSpanFromDragEvent?.(event);
if (!updatedSpan || !activeRowId) return;
const effectiveMinDuration =
totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs;
if (clampedSpan.end - clampedSpan.start < effectiveMinDuration) {
return;
}
const activeItemId = event.active.id as string;
let clampedSpan = clampSpanToBounds(updatedSpan);
// Clamp to neighbour boundaries instead of rejecting
if (hasOverlap(clampedSpan, activeItemId)) {
clampedSpan = clampToNeighbours(clampedSpan, activeItemId);
// If still overlapping after clamping, fall back to original position
if (hasOverlap(clampedSpan, activeItemId)) {
return;
}
}
// Clamp to neighbour boundaries instead of rejecting
if (hasOverlap(clampedSpan, activeItemId)) {
clampedSpan = clampToNeighbours(clampedSpan, activeItemId);
if (hasOverlap(clampedSpan, activeItemId)) {
return;
}
}
onItemSpanChange(activeItemId, clampedSpan);
},
[
clampSpanToBounds,
clampToNeighbours,
hasOverlap,
minItemDurationMs,
onItemSpanChange,
totalMs,
],
);
onItemSpanChange(activeItemId, clampedSpan);
},
[clampSpanToBounds, clampToNeighbours, hasOverlap, onItemSpanChange]
);
const onDragEnd = useCallback(
(event: DragEndEvent) => {
const activeRowId = event.over?.id as string;
const updatedSpan = event.active.data.current.getSpanFromDragEvent?.(event);
if (!updatedSpan || !activeRowId) return;
// Drag/resize tooltip (direct DOM updates, no re-renders)
const tooltipRef = useRef<HTMLDivElement>(null);
const activeItemId = event.active.id as string;
let clampedSpan = clampSpanToBounds(updatedSpan);
const formatTooltipMs = (ms: number) => {
const s = ms / 1000;
const min = Math.floor(s / 60);
const sec = s % 60;
return min > 0
? `${min}:${sec.toFixed(1).padStart(4, '0')}`
: `${sec.toFixed(1)}s`;
};
// Clamp to neighbour boundaries instead of rejecting
if (hasOverlap(clampedSpan, activeItemId)) {
clampedSpan = clampToNeighbours(clampedSpan, activeItemId);
if (hasOverlap(clampedSpan, activeItemId)) {
return;
}
}
const showTooltip = useCallback(
(span: { start: number; end: number } | null, screenX?: number) => {
const el = tooltipRef.current;
if (!el) return;
if (!span) {
el.style.opacity = '0';
return;
}
el.textContent = `${formatTooltipMs(span.start)} ${formatTooltipMs(span.end)}`;
el.style.opacity = '1';
if (screenX !== undefined) {
const parent = el.parentElement;
if (parent) {
const rect = parent.getBoundingClientRect();
const x = Math.max(0, Math.min(screenX - rect.left, rect.width - 100));
el.style.left = `${x}px`;
}
}
},
[],
);
onItemSpanChange(activeItemId, clampedSpan);
},
[clampSpanToBounds, clampToNeighbours, hasOverlap, onItemSpanChange],
);
const onDragStart = useCallback(
(event: DragStartEvent) => {
const span = event.active.data.current.getSpanFromDragEvent?.(event);
if (span) showTooltip(span);
},
[showTooltip],
);
// Drag/resize tooltip (direct DOM updates, no re-renders)
const tooltipRef = useRef<HTMLDivElement>(null);
const onDragMove = useCallback(
(event: DragMoveEvent) => {
const span = event.active.data.current.getSpanFromDragEvent?.(event);
const screenX = event.activatorEvent && 'clientX' in event.activatorEvent
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
: undefined;
if (span) showTooltip(span, screenX);
},
[showTooltip],
);
const formatTooltipMs = (ms: number) => {
const s = ms / 1000;
const min = Math.floor(s / 60);
const sec = s % 60;
return min > 0 ? `${min}:${sec.toFixed(1).padStart(4, "0")}` : `${sec.toFixed(1)}s`;
};
const onResizeMove = useCallback(
(event: ResizeMoveEvent) => {
const span = event.active.data.current.getSpanFromResizeEvent?.(event);
const screenX = event.activatorEvent && 'clientX' in event.activatorEvent
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
: undefined;
if (span) showTooltip(span, screenX);
},
[showTooltip],
);
const showTooltip = useCallback(
(span: { start: number; end: number } | null, screenX?: number) => {
const el = tooltipRef.current;
if (!el) return;
if (!span) {
el.style.opacity = "0";
return;
}
el.textContent = `${formatTooltipMs(span.start)} ${formatTooltipMs(span.end)}`;
el.style.opacity = "1";
if (screenX !== undefined) {
const parent = el.parentElement;
if (parent) {
const rect = parent.getBoundingClientRect();
const x = Math.max(0, Math.min(screenX - rect.left, rect.width - 100));
el.style.left = `${x}px`;
}
}
},
[],
);
const hideTooltip = useCallback(() => showTooltip(null), [showTooltip]);
const onDragStart = useCallback(
(event: DragStartEvent) => {
const span = event.active.data.current.getSpanFromDragEvent?.(event);
if (span) showTooltip(span);
},
[showTooltip],
);
const onResizeEndWithTooltip = useCallback(
(event: ResizeEndEvent) => {
hideTooltip();
onResizeEnd(event);
},
[hideTooltip, onResizeEnd],
);
const onDragMove = useCallback(
(event: DragMoveEvent) => {
const span = event.active.data.current.getSpanFromDragEvent?.(event);
const screenX =
event.activatorEvent && "clientX" in event.activatorEvent
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
: undefined;
if (span) showTooltip(span, screenX);
},
[showTooltip],
);
const onDragEndWithTooltip = useCallback(
(event: DragEndEvent) => {
hideTooltip();
onDragEnd(event);
},
[hideTooltip, onDragEnd],
);
const onResizeMove = useCallback(
(event: ResizeMoveEvent) => {
const span = event.active.data.current.getSpanFromResizeEvent?.(event);
const screenX =
event.activatorEvent && "clientX" in event.activatorEvent
? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0)
: undefined;
if (span) showTooltip(span, screenX);
},
[showTooltip],
);
const handleRangeChange = useCallback(
(updater: (previous: Range) => Range) => {
onRangeChange((prev) => {
const normalized = totalMs > 0 ? clampRange(prev) : prev;
const desired = updater(normalized);
if (totalMs > 0) {
const clamped = clampRange(desired);
if (clamped.end > totalMs) {
const span = Math.min(clamped.end - clamped.start, totalMs);
return {
start: Math.max(0, totalMs - span),
end: totalMs,
};
}
return clamped;
}
return desired;
});
},
[clampRange, onRangeChange, totalMs],
);
const hideTooltip = useCallback(() => showTooltip(null), [showTooltip]);
return (
<TimelineContext
range={range}
onRangeChanged={handleRangeChange}
onResizeEnd={onResizeEndWithTooltip}
onResizeMove={onResizeMove}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEndWithTooltip}
autoScroll={{ enabled: false }}
>
<div className="relative">
{children}
{/* Floating tooltip shown during drag/resize */}
<div
ref={tooltipRef}
className="absolute top-1 pointer-events-none z-[60] px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-white/10 shadow-lg"
style={{ opacity: 0, transition: 'opacity 0.1s' }}
/>
</div>
</TimelineContext>
);
}
const onResizeEndWithTooltip = useCallback(
(event: ResizeEndEvent) => {
hideTooltip();
onResizeEnd(event);
},
[hideTooltip, onResizeEnd],
);
const onDragEndWithTooltip = useCallback(
(event: DragEndEvent) => {
hideTooltip();
onDragEnd(event);
},
[hideTooltip, onDragEnd],
);
const handleRangeChange = useCallback(
(updater: (previous: Range) => Range) => {
onRangeChange((prev) => {
const normalized = totalMs > 0 ? clampRange(prev) : prev;
const desired = updater(normalized);
if (totalMs > 0) {
const clamped = clampRange(desired);
if (clamped.end > totalMs) {
const span = Math.min(clamped.end - clamped.start, totalMs);
return {
start: Math.max(0, totalMs - span),
end: totalMs,
};
}
return clamped;
}
return desired;
});
},
[clampRange, onRangeChange, totalMs],
);
return (
<TimelineContext
range={range}
onRangeChanged={handleRangeChange}
onResizeEnd={onResizeEndWithTooltip}
onResizeMove={onResizeMove}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEndWithTooltip}
autoScroll={{ enabled: false }}
>
<div className="relative">
{children}
{/* Floating tooltip shown during drag/resize */}
<div
ref={tooltipRef}
className="absolute top-1 pointer-events-none z-[60] px-1.5 py-0.5 rounded bg-black/80 text-[10px] text-white/90 font-medium tabular-nums whitespace-nowrap border border-white/10 shadow-lg"
style={{ opacity: 0, transition: "opacity 0.1s" }}
/>
</div>
</TimelineContext>
);
}
@@ -5,71 +5,77 @@ export const MAX_DWELL_DURATION_MS = 2600;
export const DWELL_MOVE_THRESHOLD = 0.02;
export interface ZoomDwellCandidate {
centerTimeMs: number;
focus: ZoomFocus;
strength: number;
centerTimeMs: number;
focus: ZoomFocus;
strength: number;
}
function normalizeTelemetrySample(sample: CursorTelemetryPoint, totalMs: number): CursorTelemetryPoint {
return {
timeMs: Math.max(0, Math.min(sample.timeMs, totalMs)),
cx: Math.max(0, Math.min(sample.cx, 1)),
cy: Math.max(0, Math.min(sample.cy, 1)),
};
function normalizeTelemetrySample(
sample: CursorTelemetryPoint,
totalMs: number,
): CursorTelemetryPoint {
return {
timeMs: Math.max(0, Math.min(sample.timeMs, totalMs)),
cx: Math.max(0, Math.min(sample.cx, 1)),
cy: Math.max(0, Math.min(sample.cy, 1)),
};
}
export function normalizeCursorTelemetry(
telemetry: CursorTelemetryPoint[],
totalMs: number,
telemetry: CursorTelemetryPoint[],
totalMs: number,
): CursorTelemetryPoint[] {
return [...telemetry]
.filter((sample) => Number.isFinite(sample.timeMs) && Number.isFinite(sample.cx) && Number.isFinite(sample.cy))
.sort((a, b) => a.timeMs - b.timeMs)
.map((sample) => normalizeTelemetrySample(sample, totalMs));
return [...telemetry]
.filter(
(sample) =>
Number.isFinite(sample.timeMs) && Number.isFinite(sample.cx) && Number.isFinite(sample.cy),
)
.sort((a, b) => a.timeMs - b.timeMs)
.map((sample) => normalizeTelemetrySample(sample, totalMs));
}
export function detectZoomDwellCandidates(samples: CursorTelemetryPoint[]): ZoomDwellCandidate[] {
if (samples.length < 2) {
return [];
}
if (samples.length < 2) {
return [];
}
const dwellCandidates: ZoomDwellCandidate[] = [];
let runStart = 0;
const dwellCandidates: ZoomDwellCandidate[] = [];
let runStart = 0;
const pushRunIfDwell = (startIndex: number, endIndexExclusive: number) => {
if (endIndexExclusive - startIndex < 2) {
return;
}
const pushRunIfDwell = (startIndex: number, endIndexExclusive: number) => {
if (endIndexExclusive - startIndex < 2) {
return;
}
const start = samples[startIndex];
const end = samples[endIndexExclusive - 1];
const runDuration = end.timeMs - start.timeMs;
if (runDuration < MIN_DWELL_DURATION_MS || runDuration > MAX_DWELL_DURATION_MS) {
return;
}
const start = samples[startIndex];
const end = samples[endIndexExclusive - 1];
const runDuration = end.timeMs - start.timeMs;
if (runDuration < MIN_DWELL_DURATION_MS || runDuration > MAX_DWELL_DURATION_MS) {
return;
}
const runSamples = samples.slice(startIndex, endIndexExclusive);
const avgCx = runSamples.reduce((sum, sample) => sum + sample.cx, 0) / runSamples.length;
const avgCy = runSamples.reduce((sum, sample) => sum + sample.cy, 0) / runSamples.length;
const runSamples = samples.slice(startIndex, endIndexExclusive);
const avgCx = runSamples.reduce((sum, sample) => sum + sample.cx, 0) / runSamples.length;
const avgCy = runSamples.reduce((sum, sample) => sum + sample.cy, 0) / runSamples.length;
dwellCandidates.push({
centerTimeMs: Math.round((start.timeMs + end.timeMs) / 2),
focus: { cx: avgCx, cy: avgCy },
strength: runDuration,
});
};
dwellCandidates.push({
centerTimeMs: Math.round((start.timeMs + end.timeMs) / 2),
focus: { cx: avgCx, cy: avgCy },
strength: runDuration,
});
};
for (let index = 1; index < samples.length; index += 1) {
const prev = samples[index - 1];
const curr = samples[index];
const distance = Math.hypot(curr.cx - prev.cx, curr.cy - prev.cy);
for (let index = 1; index < samples.length; index += 1) {
const prev = samples[index - 1];
const curr = samples[index];
const distance = Math.hypot(curr.cx - prev.cx, curr.cy - prev.cy);
if (distance > DWELL_MOVE_THRESHOLD) {
pushRunIfDwell(runStart, index);
runStart = index;
}
}
pushRunIfDwell(runStart, samples.length);
if (distance > DWELL_MOVE_THRESHOLD) {
pushRunIfDwell(runStart, index);
runStart = index;
}
}
pushRunIfDwell(runStart, samples.length);
return dwellCandidates;
return dwellCandidates;
}
+96 -90
View File
@@ -1,159 +1,165 @@
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
export interface ZoomFocus {
cx: number; // normalized horizontal center (0-1)
cy: number; // normalized vertical center (0-1)
cx: number; // normalized horizontal center (0-1)
cy: number; // normalized vertical center (0-1)
}
export interface ZoomRegion {
id: string;
startMs: number;
endMs: number;
depth: ZoomDepth;
focus: ZoomFocus;
id: string;
startMs: number;
endMs: number;
depth: ZoomDepth;
focus: ZoomFocus;
}
export interface CursorTelemetryPoint {
timeMs: number;
cx: number;
cy: number;
timeMs: number;
cx: number;
cy: number;
}
export interface TrimRegion {
id: string;
startMs: number;
endMs: number;
id: string;
startMs: number;
endMs: number;
}
export type AnnotationType = 'text' | 'image' | 'figure';
export type AnnotationType = "text" | "image" | "figure";
export type ArrowDirection = 'up' | 'down' | 'left' | 'right' | 'up-right' | 'up-left' | 'down-right' | 'down-left';
export type ArrowDirection =
| "up"
| "down"
| "left"
| "right"
| "up-right"
| "up-left"
| "down-right"
| "down-left";
export interface FigureData {
arrowDirection: ArrowDirection;
color: string;
strokeWidth: number;
arrowDirection: ArrowDirection;
color: string;
strokeWidth: number;
}
export interface AnnotationPosition {
x: number;
y: number;
x: number;
y: number;
}
export interface AnnotationSize {
width: number;
height: number;
width: number;
height: number;
}
export interface AnnotationTextStyle {
color: string;
backgroundColor: string;
fontSize: number; // pixels
fontFamily: string;
fontWeight: 'normal' | 'bold';
fontStyle: 'normal' | 'italic';
textDecoration: 'none' | 'underline';
textAlign: 'left' | 'center' | 'right';
color: string;
backgroundColor: string;
fontSize: number; // pixels
fontFamily: string;
fontWeight: "normal" | "bold";
fontStyle: "normal" | "italic";
textDecoration: "none" | "underline";
textAlign: "left" | "center" | "right";
}
export interface AnnotationRegion {
id: string;
startMs: number;
endMs: number;
type: AnnotationType;
content: string; // Legacy - still used for current type
textContent?: string; // Separate storage for text
imageContent?: string; // Separate storage for image data URL
position: AnnotationPosition;
size: AnnotationSize;
style: AnnotationTextStyle;
zIndex: number;
figureData?: FigureData;
id: string;
startMs: number;
endMs: number;
type: AnnotationType;
content: string; // Legacy - still used for current type
textContent?: string; // Separate storage for text
imageContent?: string; // Separate storage for image data URL
position: AnnotationPosition;
size: AnnotationSize;
style: AnnotationTextStyle;
zIndex: number;
figureData?: FigureData;
}
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
x: 50,
y: 50,
x: 50,
y: 50,
};
export const DEFAULT_ANNOTATION_SIZE: AnnotationSize = {
width: 30,
height: 20,
width: 30,
height: 20,
};
export const DEFAULT_ANNOTATION_STYLE: AnnotationTextStyle = {
color: '#ffffff',
backgroundColor: 'transparent',
fontSize: 32,
fontFamily: 'Inter',
fontWeight: 'bold',
fontStyle: 'normal',
textDecoration: 'none',
textAlign: 'center',
color: "#ffffff",
backgroundColor: "transparent",
fontSize: 32,
fontFamily: "Inter",
fontWeight: "bold",
fontStyle: "normal",
textDecoration: "none",
textAlign: "center",
};
export const DEFAULT_FIGURE_DATA: FigureData = {
arrowDirection: 'right',
color: '#34B27B',
strokeWidth: 4,
arrowDirection: "right",
color: "#34B27B",
strokeWidth: 4,
};
export interface CropRegion {
x: number;
y: number;
width: number;
height: number;
x: number;
y: number;
width: number;
height: number;
}
export const DEFAULT_CROP_REGION: CropRegion = {
x: 0,
y: 0,
width: 1,
height: 1,
x: 0,
y: 0,
width: 1,
height: 1,
};
export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;
export interface SpeedRegion {
id: string;
startMs: number;
endMs: number;
speed: PlaybackSpeed;
id: string;
startMs: number;
endMs: number;
speed: PlaybackSpeed;
}
export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [
{ speed: 0.25, label: "0.25×" },
{ speed: 0.5, label: "0.5×" },
{ speed: 0.75, label: "0.75×" },
{ speed: 1.25, label: "1.25×" },
{ speed: 1.5, label: "1.5×" },
{ speed: 1.75, label: "1.75×" },
{ speed: 2, label: "2×" },
{ speed: 0.25, label: "0.25×" },
{ speed: 0.5, label: "0.5×" },
{ speed: 0.75, label: "0.75×" },
{ speed: 1.25, label: "1.25×" },
{ speed: 1.5, label: "1.5×" },
{ speed: 1.75, label: "1.75×" },
{ speed: 2, label: "2×" },
];
export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5;
export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
1: 1.25,
2: 1.5,
3: 1.8,
4: 2.2,
5: 3.5,
6: 5.0,
1: 1.25,
2: 1.5,
3: 1.8,
4: 2.2,
5: 3.5,
6: 5.0,
};
export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 3;
export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus {
return {
cx: clamp(focus.cx, 0, 1),
cy: clamp(focus.cy, 0, 1),
};
return {
cx: clamp(focus.cx, 0, 1),
cy: clamp(focus.cy, 0, 1),
};
}
function clamp(value: number, min: number, max: number) {
if (Number.isNaN(value)) return (min + max) / 2;
return Math.min(max, Math.max(min, value));
if (Number.isNaN(value)) return (min + max) / 2;
return Math.min(max, Math.max(min, value));
}
@@ -1,54 +1,60 @@
import { ZOOM_DEPTH_SCALES, clampFocusToDepth, type ZoomFocus, type ZoomDepth } from "../types";
import { clampFocusToDepth, ZOOM_DEPTH_SCALES, type ZoomDepth, type ZoomFocus } from "../types";
interface StageSize {
width: number;
height: number;
width: number;
height: number;
}
export function clampFocusToStage(
focus: ZoomFocus,
depth: ZoomDepth,
stageSize: StageSize
focus: ZoomFocus,
depth: ZoomDepth,
stageSize: StageSize,
): ZoomFocus {
if (!stageSize.width || !stageSize.height) {
return clampFocusToDepth(focus, depth);
}
if (!stageSize.width || !stageSize.height) {
return clampFocusToDepth(focus, depth);
}
const zoomScale = ZOOM_DEPTH_SCALES[depth];
const windowWidth = stageSize.width / zoomScale;
const windowHeight = stageSize.height / zoomScale;
const marginX = windowWidth / (2 * stageSize.width);
const marginY = windowHeight / (2 * stageSize.height);
const zoomScale = ZOOM_DEPTH_SCALES[depth];
const baseFocus = clampFocusToDepth(focus, depth);
const windowWidth = stageSize.width / zoomScale;
const windowHeight = stageSize.height / zoomScale;
return {
cx: Math.max(marginX, Math.min(1 - marginX, baseFocus.cx)),
cy: Math.max(marginY, Math.min(1 - marginY, baseFocus.cy)),
};
const marginX = windowWidth / (2 * stageSize.width);
const marginY = windowHeight / (2 * stageSize.height);
const baseFocus = clampFocusToDepth(focus, depth);
return {
cx: Math.max(marginX, Math.min(1 - marginX, baseFocus.cx)),
cy: Math.max(marginY, Math.min(1 - marginY, baseFocus.cy)),
};
}
export function stageFocusToVideoSpace(
focus: ZoomFocus,
stageSize: StageSize,
videoSize: { width: number; height: number },
baseScale: number,
baseOffset: { x: number; y: number }
focus: ZoomFocus,
stageSize: StageSize,
videoSize: { width: number; height: number },
baseScale: number,
baseOffset: { x: number; y: number },
): ZoomFocus {
if (!stageSize.width || !stageSize.height || !videoSize.width || !videoSize.height || baseScale <= 0) {
return focus;
}
if (
!stageSize.width ||
!stageSize.height ||
!videoSize.width ||
!videoSize.height ||
baseScale <= 0
) {
return focus;
}
const stageX = focus.cx * stageSize.width;
const stageY = focus.cy * stageSize.height;
const stageX = focus.cx * stageSize.width;
const stageY = focus.cy * stageSize.height;
const videoNormX = (stageX - baseOffset.x) / (videoSize.width * baseScale);
const videoNormY = (stageY - baseOffset.y) / (videoSize.height * baseScale);
const videoNormX = (stageX - baseOffset.x) / (videoSize.width * baseScale);
const videoNormY = (stageY - baseOffset.y) / (videoSize.height * baseScale);
return {
cx: videoNormX,
cy: videoNormY,
};
return {
cx: videoNormX,
cy: videoNormY,
};
}
@@ -1,8 +1,8 @@
export * from './constants';
export * from './mathUtils';
export * from './zoomRegionUtils';
export * from './focusUtils';
export * from './overlayUtils';
export * from './layoutUtils';
export * from './zoomTransform';
export * from './videoEventHandlers';
export * from "./constants";
export * from "./focusUtils";
export * from "./layoutUtils";
export * from "./mathUtils";
export * from "./overlayUtils";
export * from "./videoEventHandlers";
export * from "./zoomRegionUtils";
export * from "./zoomTransform";
@@ -1,111 +1,121 @@
import { Application, Sprite, Graphics } from 'pixi.js';
import type { CropRegion } from '../types';
import { Application, Graphics, Sprite } from "pixi.js";
import type { CropRegion } from "../types";
interface LayoutParams {
container: HTMLDivElement;
app: Application;
videoSprite: Sprite;
maskGraphics: Graphics;
videoElement: HTMLVideoElement;
cropRegion?: CropRegion;
lockedVideoDimensions?: { width: number; height: number } | null;
borderRadius?: number;
padding?: number;
container: HTMLDivElement;
app: Application;
videoSprite: Sprite;
maskGraphics: Graphics;
videoElement: HTMLVideoElement;
cropRegion?: CropRegion;
lockedVideoDimensions?: { width: number; height: number } | null;
borderRadius?: number;
padding?: number;
}
interface LayoutResult {
stageSize: { width: number; height: number };
videoSize: { width: number; height: number };
baseScale: number;
baseOffset: { x: number; y: number };
maskRect: { x: number; y: number; width: number; height: number };
cropBounds: { startX: number; endX: number; startY: number; endY: number };
stageSize: { width: number; height: number };
videoSize: { width: number; height: number };
baseScale: number;
baseOffset: { x: number; y: number };
maskRect: { x: number; y: number; width: number; height: number };
cropBounds: { startX: number; endX: number; startY: number; endY: number };
}
export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
const { container, app, videoSprite, maskGraphics, videoElement, cropRegion, lockedVideoDimensions, borderRadius = 0, padding = 0 } = params;
const {
container,
app,
videoSprite,
maskGraphics,
videoElement,
cropRegion,
lockedVideoDimensions,
borderRadius = 0,
padding = 0,
} = params;
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
const videoHeight = lockedVideoDimensions?.height || videoElement.videoHeight;
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
const videoHeight = lockedVideoDimensions?.height || videoElement.videoHeight;
if (!videoWidth || !videoHeight) {
return null;
}
if (!videoWidth || !videoHeight) {
return null;
}
const width = container.clientWidth;
const height = container.clientHeight;
const width = container.clientWidth;
const height = container.clientHeight;
if (!width || !height) {
return null;
}
if (!width || !height) {
return null;
}
app.renderer.resize(width, height);
app.canvas.style.width = '100%';
app.canvas.style.height = '100%';
app.renderer.resize(width, height);
app.canvas.style.width = "100%";
app.canvas.style.height = "100%";
// Apply crop region
const crop = cropRegion || { x: 0, y: 0, width: 1, height: 1 };
// Calculate the cropped dimensions
const croppedVideoWidth = videoWidth * crop.width;
const croppedVideoHeight = videoHeight * crop.height;
// Apply crop region
const crop = cropRegion || { x: 0, y: 0, width: 1, height: 1 };
const cropStartX = crop.x * videoWidth;
const cropStartY = crop.y * videoHeight;
const cropEndX = cropStartX + croppedVideoWidth;
const cropEndY = cropStartY + croppedVideoHeight;
// Calculate scale to fit the cropped area in the viewport
// Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8
const paddingScale = 1.0 - (padding / 100) * 0.4;
const maxDisplayWidth = width * paddingScale;
const maxDisplayHeight = height * paddingScale;
// Calculate the cropped dimensions
const croppedVideoWidth = videoWidth * crop.width;
const croppedVideoHeight = videoHeight * crop.height;
const scale = Math.min(
maxDisplayWidth / croppedVideoWidth,
maxDisplayHeight / croppedVideoHeight,
1
);
const cropStartX = crop.x * videoWidth;
const cropStartY = crop.y * videoHeight;
const cropEndX = cropStartX + croppedVideoWidth;
const cropEndY = cropStartY + croppedVideoHeight;
videoSprite.scale.set(scale);
// Calculate display size of the full video at this scale
const fullVideoDisplayWidth = videoWidth * scale;
const fullVideoDisplayHeight = videoHeight * scale;
// Calculate display size of just the cropped region
const croppedDisplayWidth = croppedVideoWidth * scale;
const croppedDisplayHeight = croppedVideoHeight * scale;
// Calculate scale to fit the cropped area in the viewport
// Padding is a percentage (0-100), where 50 matches the original VIEWPORT_SCALE of 0.8
const paddingScale = 1.0 - (padding / 100) * 0.4;
const maxDisplayWidth = width * paddingScale;
const maxDisplayHeight = height * paddingScale;
// Center the cropped region in the container
const centerOffsetX = (width - croppedDisplayWidth) / 2;
const centerOffsetY = (height - croppedDisplayHeight) / 2;
// Position the full video sprite so that when we apply the mask,
// the cropped region appears centered
// The crop starts at (crop.x * videoWidth, crop.y * videoHeight) in video coordinates
// In display coordinates, that's (crop.x * fullVideoDisplayWidth, crop.y * fullVideoDisplayHeight)
// We want that point to be at centerOffsetX, centerOffsetY
const spriteX = centerOffsetX - (crop.x * fullVideoDisplayWidth);
const spriteY = centerOffsetY - (crop.y * fullVideoDisplayHeight);
videoSprite.position.set(spriteX, spriteY);
const scale = Math.min(
maxDisplayWidth / croppedVideoWidth,
maxDisplayHeight / croppedVideoHeight,
1,
);
// Create a mask that only shows the cropped region (centered in container)
const maskX = centerOffsetX;
const maskY = centerOffsetY;
// Apply border radius
maskGraphics.clear();
maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, borderRadius);
maskGraphics.fill({ color: 0xffffff });
videoSprite.scale.set(scale);
return {
stageSize: { width, height },
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
baseScale: scale,
baseOffset: { x: spriteX, y: spriteY },
maskRect: { x: maskX, y: maskY, width: croppedDisplayWidth, height: croppedDisplayHeight },
cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY },
};
// Calculate display size of the full video at this scale
const fullVideoDisplayWidth = videoWidth * scale;
const fullVideoDisplayHeight = videoHeight * scale;
// Calculate display size of just the cropped region
const croppedDisplayWidth = croppedVideoWidth * scale;
const croppedDisplayHeight = croppedVideoHeight * scale;
// Center the cropped region in the container
const centerOffsetX = (width - croppedDisplayWidth) / 2;
const centerOffsetY = (height - croppedDisplayHeight) / 2;
// Position the full video sprite so that when we apply the mask,
// the cropped region appears centered
// The crop starts at (crop.x * videoWidth, crop.y * videoHeight) in video coordinates
// In display coordinates, that's (crop.x * fullVideoDisplayWidth, crop.y * fullVideoDisplayHeight)
// We want that point to be at centerOffsetX, centerOffsetY
const spriteX = centerOffsetX - crop.x * fullVideoDisplayWidth;
const spriteY = centerOffsetY - crop.y * fullVideoDisplayHeight;
videoSprite.position.set(spriteX, spriteY);
// Create a mask that only shows the cropped region (centered in container)
const maskX = centerOffsetX;
const maskY = centerOffsetY;
// Apply border radius
maskGraphics.clear();
maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, borderRadius);
maskGraphics.fill({ color: 0xffffff });
return {
stageSize: { width, height },
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
baseScale: scale,
baseOffset: { x: spriteX, y: spriteY },
maskRect: { x: maskX, y: maskY, width: croppedDisplayWidth, height: croppedDisplayHeight },
cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY },
};
}
@@ -1,8 +1,8 @@
export function clamp01(value: number) {
return Math.max(0, Math.min(1, value));
return Math.max(0, Math.min(1, value));
}
export function smoothStep(t: number) {
const clamped = clamp01(t);
return clamped * clamped * (3 - 2 * clamped);
const clamped = clamp01(t);
return clamped * clamped * (3 - 2 * clamped);
}
@@ -1,66 +1,67 @@
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus } from "../types";
import { ZOOM_DEPTH_SCALES, type ZoomFocus, type ZoomRegion } from "../types";
import { clampFocusToStage } from "./focusUtils";
interface OverlayUpdateParams {
overlayEl: HTMLDivElement;
indicatorEl: HTMLDivElement;
region: ZoomRegion | null;
focusOverride?: ZoomFocus;
videoSize: { width: number; height: number };
baseScale: number;
isPlaying: boolean;
overlayEl: HTMLDivElement;
indicatorEl: HTMLDivElement;
region: ZoomRegion | null;
focusOverride?: ZoomFocus;
videoSize: { width: number; height: number };
baseScale: number;
isPlaying: boolean;
}
export function updateOverlayIndicator(params: OverlayUpdateParams) {
const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params;
const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params;
if (!region) {
indicatorEl.style.display = 'none';
overlayEl.style.pointerEvents = 'none';
return;
}
if (!region) {
indicatorEl.style.display = "none";
overlayEl.style.pointerEvents = "none";
return;
}
const stageWidth = overlayEl.clientWidth;
const stageHeight = overlayEl.clientHeight;
if (!stageWidth || !stageHeight) {
indicatorEl.style.display = 'none';
overlayEl.style.pointerEvents = 'none';
return;
}
const stageWidth = overlayEl.clientWidth;
const stageHeight = overlayEl.clientHeight;
if (!videoSize.width || !videoSize.height || baseScale <= 0) {
indicatorEl.style.display = 'none';
overlayEl.style.pointerEvents = isPlaying ? 'none' : 'auto';
return;
}
if (!stageWidth || !stageHeight) {
indicatorEl.style.display = "none";
overlayEl.style.pointerEvents = "none";
return;
}
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
const focus = clampFocusToStage(
focusOverride ?? region.focus,
region.depth,
{ width: stageWidth, height: stageHeight }
);
if (!videoSize.width || !videoSize.height || baseScale <= 0) {
indicatorEl.style.display = "none";
overlayEl.style.pointerEvents = isPlaying ? "none" : "auto";
return;
}
// Zoom window shows the stage area that will be visible after zooming (1/zoomScale of stage dimensions)
const indicatorWidth = stageWidth / zoomScale;
const indicatorHeight = stageHeight / zoomScale;
const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
const focus = clampFocusToStage(focusOverride ?? region.focus, region.depth, {
width: stageWidth,
height: stageHeight,
});
const rawLeft = focus.cx * stageWidth - indicatorWidth / 2;
const rawTop = focus.cy * stageHeight - indicatorHeight / 2;
// Zoom window shows the stage area that will be visible after zooming (1/zoomScale of stage dimensions)
const indicatorWidth = stageWidth / zoomScale;
const indicatorHeight = stageHeight / zoomScale;
const adjustedLeft = indicatorWidth >= stageWidth
? (stageWidth - indicatorWidth) / 2
: Math.max(0, Math.min(stageWidth - indicatorWidth, rawLeft));
const rawLeft = focus.cx * stageWidth - indicatorWidth / 2;
const rawTop = focus.cy * stageHeight - indicatorHeight / 2;
const adjustedTop = indicatorHeight >= stageHeight
? (stageHeight - indicatorHeight) / 2
: Math.max(0, Math.min(stageHeight - indicatorHeight, rawTop));
const adjustedLeft =
indicatorWidth >= stageWidth
? (stageWidth - indicatorWidth) / 2
: Math.max(0, Math.min(stageWidth - indicatorWidth, rawLeft));
indicatorEl.style.display = 'block';
indicatorEl.style.width = `${indicatorWidth}px`;
indicatorEl.style.height = `${indicatorHeight}px`;
indicatorEl.style.left = `${adjustedLeft}px`;
indicatorEl.style.top = `${adjustedTop}px`;
overlayEl.style.pointerEvents = isPlaying ? 'none' : 'auto';
const adjustedTop =
indicatorHeight >= stageHeight
? (stageHeight - indicatorHeight) / 2
: Math.max(0, Math.min(stageHeight - indicatorHeight, rawTop));
indicatorEl.style.display = "block";
indicatorEl.style.width = `${indicatorWidth}px`;
indicatorEl.style.height = `${indicatorHeight}px`;
indicatorEl.style.left = `${adjustedLeft}px`;
indicatorEl.style.top = `${adjustedTop}px`;
overlayEl.style.pointerEvents = isPlaying ? "none" : "auto";
}
@@ -1,148 +1,152 @@
import type React from 'react';
import type { TrimRegion, SpeedRegion } from '../types';
import type React from "react";
import type { SpeedRegion, TrimRegion } from "../types";
interface VideoEventHandlersParams {
video: HTMLVideoElement;
isSeekingRef: React.MutableRefObject<boolean>;
isPlayingRef: React.MutableRefObject<boolean>;
allowPlaybackRef: React.MutableRefObject<boolean>;
currentTimeRef: React.MutableRefObject<number>;
timeUpdateAnimationRef: React.MutableRefObject<number | null>;
onPlayStateChange: (playing: boolean) => void;
onTimeUpdate: (time: number) => void;
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
speedRegionsRef: React.MutableRefObject<SpeedRegion[]>;
video: HTMLVideoElement;
isSeekingRef: React.MutableRefObject<boolean>;
isPlayingRef: React.MutableRefObject<boolean>;
allowPlaybackRef: React.MutableRefObject<boolean>;
currentTimeRef: React.MutableRefObject<number>;
timeUpdateAnimationRef: React.MutableRefObject<number | null>;
onPlayStateChange: (playing: boolean) => void;
onTimeUpdate: (time: number) => void;
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
speedRegionsRef: React.MutableRefObject<SpeedRegion[]>;
}
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
const {
video,
isSeekingRef,
isPlayingRef,
allowPlaybackRef,
currentTimeRef,
timeUpdateAnimationRef,
onPlayStateChange,
onTimeUpdate,
trimRegionsRef,
speedRegionsRef,
} = params;
const {
video,
isSeekingRef,
isPlayingRef,
allowPlaybackRef,
currentTimeRef,
timeUpdateAnimationRef,
onPlayStateChange,
onTimeUpdate,
trimRegionsRef,
speedRegionsRef,
} = params;
const emitTime = (timeValue: number) => {
currentTimeRef.current = timeValue * 1000;
onTimeUpdate(timeValue);
};
const emitTime = (timeValue: number) => {
currentTimeRef.current = timeValue * 1000;
onTimeUpdate(timeValue);
};
// Helper function to check if current time is within a trim region
const findActiveTrimRegion = (currentTimeMs: number): TrimRegion | null => {
const trimRegions = trimRegionsRef.current;
return trimRegions.find(
(region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs
) || null;
};
// Helper function to check if current time is within a trim region
const findActiveTrimRegion = (currentTimeMs: number): TrimRegion | null => {
const trimRegions = trimRegionsRef.current;
return (
trimRegions.find(
(region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs,
) || null
);
};
// Helper function to find the active speed region at the current time
const findActiveSpeedRegion = (currentTimeMs: number): SpeedRegion | null => {
return speedRegionsRef.current.find(
(region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs
) || null;
};
// Helper function to find the active speed region at the current time
const findActiveSpeedRegion = (currentTimeMs: number): SpeedRegion | null => {
return (
speedRegionsRef.current.find(
(region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs,
) || null
);
};
function updateTime() {
if (!video) return;
function updateTime() {
if (!video) return;
const currentTimeMs = video.currentTime * 1000;
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
const currentTimeMs = video.currentTime * 1000;
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
// If we're in a trim region during playback, skip to the end of it
if (activeTrimRegion && !video.paused && !video.ended) {
const skipToTime = activeTrimRegion.endMs / 1000;
// If we're in a trim region during playback, skip to the end of it
if (activeTrimRegion && !video.paused && !video.ended) {
const skipToTime = activeTrimRegion.endMs / 1000;
// If the skip would take us past the video duration, pause instead
if (skipToTime >= video.duration) {
video.pause();
} else {
video.currentTime = skipToTime;
emitTime(skipToTime);
}
} else {
// Apply playback speed from active speed region
const activeSpeedRegion = findActiveSpeedRegion(currentTimeMs);
video.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
emitTime(video.currentTime);
}
// If the skip would take us past the video duration, pause instead
if (skipToTime >= video.duration) {
video.pause();
} else {
video.currentTime = skipToTime;
emitTime(skipToTime);
}
} else {
// Apply playback speed from active speed region
const activeSpeedRegion = findActiveSpeedRegion(currentTimeMs);
video.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
emitTime(video.currentTime);
}
if (!video.paused && !video.ended) {
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
}
}
if (!video.paused && !video.ended) {
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
}
}
const handlePlay = () => {
if (isSeekingRef.current) {
video.pause();
return;
}
const handlePlay = () => {
if (isSeekingRef.current) {
video.pause();
return;
}
if (!allowPlaybackRef.current) {
video.pause();
return;
}
if (!allowPlaybackRef.current) {
video.pause();
return;
}
isPlayingRef.current = true;
onPlayStateChange(true);
if (timeUpdateAnimationRef.current) {
cancelAnimationFrame(timeUpdateAnimationRef.current);
}
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
};
isPlayingRef.current = true;
onPlayStateChange(true);
if (timeUpdateAnimationRef.current) {
cancelAnimationFrame(timeUpdateAnimationRef.current);
}
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
};
const handlePause = () => {
isPlayingRef.current = false;
onPlayStateChange(false);
if (timeUpdateAnimationRef.current) {
cancelAnimationFrame(timeUpdateAnimationRef.current);
timeUpdateAnimationRef.current = null;
}
emitTime(video.currentTime);
};
const handlePause = () => {
isPlayingRef.current = false;
onPlayStateChange(false);
if (timeUpdateAnimationRef.current) {
cancelAnimationFrame(timeUpdateAnimationRef.current);
timeUpdateAnimationRef.current = null;
}
emitTime(video.currentTime);
};
const handleSeeked = () => {
isSeekingRef.current = false;
const handleSeeked = () => {
isSeekingRef.current = false;
const currentTimeMs = video.currentTime * 1000;
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
// If we seeked into a trim region while playing, skip to the end
if (activeTrimRegion && isPlayingRef.current && !video.paused) {
const skipToTime = activeTrimRegion.endMs / 1000;
if (skipToTime >= video.duration) {
video.pause();
} else {
video.currentTime = skipToTime;
emitTime(skipToTime);
}
} else {
if (!isPlayingRef.current && !video.paused) {
video.pause();
}
emitTime(video.currentTime);
}
};
const currentTimeMs = video.currentTime * 1000;
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
const handleSeeking = () => {
isSeekingRef.current = true;
// If we seeked into a trim region while playing, skip to the end
if (activeTrimRegion && isPlayingRef.current && !video.paused) {
const skipToTime = activeTrimRegion.endMs / 1000;
if (!isPlayingRef.current && !video.paused) {
video.pause();
}
emitTime(video.currentTime);
};
if (skipToTime >= video.duration) {
video.pause();
} else {
video.currentTime = skipToTime;
emitTime(skipToTime);
}
} else {
if (!isPlayingRef.current && !video.paused) {
video.pause();
}
emitTime(video.currentTime);
}
};
return {
handlePlay,
handlePause,
handleSeeked,
handleSeeking,
};
const handleSeeking = () => {
isSeekingRef.current = true;
if (!isPlayingRef.current && !video.paused) {
video.pause();
}
emitTime(video.currentTime);
};
return {
handlePlay,
handlePause,
handleSeeked,
handleSeeking,
};
}
@@ -1,31 +1,31 @@
import type { ZoomRegion } from "../types";
import { smoothStep } from "./mathUtils";
import { TRANSITION_WINDOW_MS } from "./constants";
import { smoothStep } from "./mathUtils";
export function computeRegionStrength(region: ZoomRegion, timeMs: number) {
const leadInStart = region.startMs - TRANSITION_WINDOW_MS;
const leadOutEnd = region.endMs + TRANSITION_WINDOW_MS;
const leadInStart = region.startMs - TRANSITION_WINDOW_MS;
const leadOutEnd = region.endMs + TRANSITION_WINDOW_MS;
if (timeMs < leadInStart || timeMs > leadOutEnd) {
return 0;
}
if (timeMs < leadInStart || timeMs > leadOutEnd) {
return 0;
}
const fadeIn = smoothStep((timeMs - leadInStart) / TRANSITION_WINDOW_MS);
const fadeOut = smoothStep((leadOutEnd - timeMs) / TRANSITION_WINDOW_MS);
return Math.min(fadeIn, fadeOut);
const fadeIn = smoothStep((timeMs - leadInStart) / TRANSITION_WINDOW_MS);
const fadeOut = smoothStep((leadOutEnd - timeMs) / TRANSITION_WINDOW_MS);
return Math.min(fadeIn, fadeOut);
}
export function findDominantRegion(regions: ZoomRegion[], timeMs: number) {
let bestRegion: ZoomRegion | null = null;
let bestStrength = 0;
let bestRegion: ZoomRegion | null = null;
let bestStrength = 0;
for (const region of regions) {
const strength = computeRegionStrength(region, timeMs);
if (strength > bestStrength) {
bestStrength = strength;
bestRegion = region;
}
}
for (const region of regions) {
const strength = computeRegionStrength(region, timeMs);
if (strength > bestStrength) {
bestStrength = strength;
bestRegion = region;
}
}
return { region: bestRegion, strength: bestStrength };
return { region: bestRegion, strength: bestStrength };
}
@@ -1,61 +1,61 @@
import { Container, BlurFilter } from 'pixi.js';
import { BlurFilter, Container } from "pixi.js";
interface TransformParams {
cameraContainer: Container;
blurFilter: BlurFilter | null;
stageSize: { width: number; height: number };
baseMask: { x: number; y: number; width: number; height: number };
zoomScale: number;
focusX: number;
focusY: number;
motionIntensity: number;
isPlaying: boolean;
motionBlurEnabled?: boolean;
cameraContainer: Container;
blurFilter: BlurFilter | null;
stageSize: { width: number; height: number };
baseMask: { x: number; y: number; width: number; height: number };
zoomScale: number;
focusX: number;
focusY: number;
motionIntensity: number;
isPlaying: boolean;
motionBlurEnabled?: boolean;
}
export function applyZoomTransform({
cameraContainer,
blurFilter,
stageSize,
baseMask,
zoomScale,
focusX,
focusY,
motionIntensity,
isPlaying,
motionBlurEnabled = false,
cameraContainer,
blurFilter,
stageSize,
baseMask,
zoomScale,
focusX,
focusY,
motionIntensity,
isPlaying,
motionBlurEnabled = false,
}: TransformParams) {
if (
stageSize.width <= 0 ||
stageSize.height <= 0 ||
baseMask.width <= 0 ||
baseMask.height <= 0
) {
return;
}
if (
stageSize.width <= 0 ||
stageSize.height <= 0 ||
baseMask.width <= 0 ||
baseMask.height <= 0
) {
return;
}
// The focus point in stage coordinates (where the user clicked/selected)
const focusStagePxX = focusX * stageSize.width;
const focusStagePxY = focusY * stageSize.height;
// Stage center (where we want the focus to end up after zoom)
const stageCenterX = stageSize.width / 2;
const stageCenterY = stageSize.height / 2;
// The focus point in stage coordinates (where the user clicked/selected)
const focusStagePxX = focusX * stageSize.width;
const focusStagePxY = focusY * stageSize.height;
// Apply zoom scale to camera container
cameraContainer.scale.set(zoomScale);
// Stage center (where we want the focus to end up after zoom)
const stageCenterX = stageSize.width / 2;
const stageCenterY = stageSize.height / 2;
// Calculate camera position to keep focus point centered
// After scaling, the focus point moves to (focusX * zoomScale, focusY * zoomScale)
// We want it at stage center, so offset = center - (focus * scale)
const cameraX = stageCenterX - focusStagePxX * zoomScale;
const cameraY = stageCenterY - focusStagePxY * zoomScale;
// Apply zoom scale to camera container
cameraContainer.scale.set(zoomScale);
cameraContainer.position.set(cameraX, cameraY);
// Calculate camera position to keep focus point centered
// After scaling, the focus point moves to (focusX * zoomScale, focusY * zoomScale)
// We want it at stage center, so offset = center - (focus * scale)
const cameraX = stageCenterX - focusStagePxX * zoomScale;
const cameraY = stageCenterY - focusStagePxY * zoomScale;
if (blurFilter) {
const shouldBlur = motionBlurEnabled && isPlaying && motionIntensity > 0.0005;
const motionBlur = shouldBlur ? Math.min(6, motionIntensity * 120) : 0;
blurFilter.blur = motionBlur;
}
cameraContainer.position.set(cameraX, cameraY);
if (blurFilter) {
const shouldBlur = motionBlurEnabled && isPlaying && motionIntensity > 0.0005;
const motionBlur = shouldBlur ? Math.min(6, motionIntensity * 120) : 0;
blurFilter.blur = motionBlur;
}
}
+58 -43
View File
@@ -1,60 +1,75 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { DEFAULT_SHORTCUTS, mergeWithDefaults, type ShortcutsConfig } from '@/lib/shortcuts';
import { isMac as getIsMac } from '@/utils/platformUtils';
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { DEFAULT_SHORTCUTS, mergeWithDefaults, type ShortcutsConfig } from "@/lib/shortcuts";
import { isMac as getIsMac } from "@/utils/platformUtils";
interface ShortcutsContextValue {
shortcuts: ShortcutsConfig;
isMac: boolean;
setShortcuts: (config: ShortcutsConfig) => void;
persistShortcuts: (config?: ShortcutsConfig) => Promise<void>;
isConfigOpen: boolean;
openConfig: () => void;
closeConfig: () => void;
shortcuts: ShortcutsConfig;
isMac: boolean;
setShortcuts: (config: ShortcutsConfig) => void;
persistShortcuts: (config?: ShortcutsConfig) => Promise<void>;
isConfigOpen: boolean;
openConfig: () => void;
closeConfig: () => void;
}
const ShortcutsContext = createContext<ShortcutsContextValue | null>(null);
export function useShortcuts(): ShortcutsContextValue {
const ctx = useContext(ShortcutsContext);
if (!ctx) throw new Error('useShortcuts must be used within <ShortcutsProvider>');
return ctx;
const ctx = useContext(ShortcutsContext);
if (!ctx) throw new Error("useShortcuts must be used within <ShortcutsProvider>");
return ctx;
}
export function ShortcutsProvider({ children }: { children: ReactNode }) {
const [shortcuts, setShortcuts] = useState<ShortcutsConfig>(DEFAULT_SHORTCUTS);
const [isMac, setIsMac] = useState(false);
const [isConfigOpen, setIsConfigOpen] = useState(false);
const [shortcuts, setShortcuts] = useState<ShortcutsConfig>(DEFAULT_SHORTCUTS);
const [isMac, setIsMac] = useState(false);
const [isConfigOpen, setIsConfigOpen] = useState(false);
useEffect(() => {
getIsMac().then(setIsMac).catch(() => {});
useEffect(() => {
getIsMac()
.then(setIsMac)
.catch(() => {});
window.electronAPI.getShortcuts?.()
.then((saved) => {
if (saved) {
setShortcuts(mergeWithDefaults(saved as Partial<ShortcutsConfig>));
}
})
.catch(() => {});
}, []);
window.electronAPI
.getShortcuts?.()
.then((saved) => {
if (saved) {
setShortcuts(mergeWithDefaults(saved as Partial<ShortcutsConfig>));
}
})
.catch(() => {});
}, []);
const persistShortcuts = useCallback(
async (config?: ShortcutsConfig) => {
await window.electronAPI.saveShortcuts?.(config ?? shortcuts);
},
[shortcuts],
);
const persistShortcuts = useCallback(
async (config?: ShortcutsConfig) => {
await window.electronAPI.saveShortcuts?.(config ?? shortcuts);
},
[shortcuts],
);
const openConfig = useCallback(() => setIsConfigOpen(true), []);
const closeConfig = useCallback(() => setIsConfigOpen(false), []);
const openConfig = useCallback(() => setIsConfigOpen(true), []);
const closeConfig = useCallback(() => setIsConfigOpen(false), []);
const value = useMemo<ShortcutsContextValue>(
() => ({ shortcuts, isMac, setShortcuts, persistShortcuts, isConfigOpen, openConfig, closeConfig }),
[shortcuts, isMac, persistShortcuts, isConfigOpen, openConfig, closeConfig],
);
const value = useMemo<ShortcutsContextValue>(
() => ({
shortcuts,
isMac,
setShortcuts,
persistShortcuts,
isConfigOpen,
openConfig,
closeConfig,
}),
[shortcuts, isMac, persistShortcuts, isConfigOpen, openConfig, closeConfig],
);
return (
<ShortcutsContext.Provider value={value}>
{children}
</ShortcutsContext.Provider>
);
return <ShortcutsContext.Provider value={value}>{children}</ShortcutsContext.Provider>;
}
+104 -106
View File
@@ -1,106 +1,104 @@
import { useState, useEffect, useRef } from 'react';
export interface AudioLevelMeterOptions {
enabled: boolean;
deviceId?: string;
smoothingFactor?: number;
}
export function useAudioLevelMeter(options: AudioLevelMeterOptions) {
const [level, setLevel] = useState(0);
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const animationFrameRef = useRef<number | null>(null);
useEffect(() => {
if (!options.enabled) {
cleanup();
setLevel(0);
return;
}
let mounted = true;
const startMonitoring = async () => {
try {
const constraints: MediaStreamConstraints = {
audio: options.deviceId
? { deviceId: { exact: options.deviceId } }
: true,
video: false,
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
if (!mounted) {
stream.getTracks().forEach(track => track.stop());
return;
}
streamRef.current = stream;
const audioContext = new AudioContext();
audioContextRef.current = audioContext;
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = options.smoothingFactor ?? 0.8;
analyserRef.current = analyser;
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const updateLevel = () => {
if (!mounted || !analyserRef.current) return;
analyser.getByteFrequencyData(dataArray);
let sum = 0;
for (let i = 0; i < dataArray.length; i++) {
sum += dataArray[i] * dataArray[i];
}
const rms = Math.sqrt(sum / dataArray.length);
const normalizedLevel = Math.min(100, (rms / 255) * 100 * 2);
setLevel(normalizedLevel);
animationFrameRef.current = requestAnimationFrame(updateLevel);
};
updateLevel();
} catch (err) {
console.error('Error starting audio level monitoring:', err);
if (mounted) {
setLevel(0);
}
}
};
startMonitoring();
return () => {
mounted = false;
cleanup();
};
}, [options.enabled, options.deviceId, options.smoothingFactor]);
const cleanup = () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
}
analyserRef.current = null;
};
return { level };
}
import { useEffect, useRef, useState } from "react";
export interface AudioLevelMeterOptions {
enabled: boolean;
deviceId?: string;
smoothingFactor?: number;
}
export function useAudioLevelMeter(options: AudioLevelMeterOptions) {
const [level, setLevel] = useState(0);
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const animationFrameRef = useRef<number | null>(null);
useEffect(() => {
if (!options.enabled) {
cleanup();
setLevel(0);
return;
}
let mounted = true;
const startMonitoring = async () => {
try {
const constraints: MediaStreamConstraints = {
audio: options.deviceId ? { deviceId: { exact: options.deviceId } } : true,
video: false,
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
if (!mounted) {
stream.getTracks().forEach((track) => track.stop());
return;
}
streamRef.current = stream;
const audioContext = new AudioContext();
audioContextRef.current = audioContext;
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = options.smoothingFactor ?? 0.8;
analyserRef.current = analyser;
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const updateLevel = () => {
if (!mounted || !analyserRef.current) return;
analyser.getByteFrequencyData(dataArray);
let sum = 0;
for (let i = 0; i < dataArray.length; i++) {
sum += dataArray[i] * dataArray[i];
}
const rms = Math.sqrt(sum / dataArray.length);
const normalizedLevel = Math.min(100, (rms / 255) * 100 * 2);
setLevel(normalizedLevel);
animationFrameRef.current = requestAnimationFrame(updateLevel);
};
updateLevel();
} catch (err) {
console.error("Error starting audio level monitoring:", err);
if (mounted) {
setLevel(0);
}
}
};
startMonitoring();
return () => {
mounted = false;
cleanup();
};
}, [options.enabled, options.deviceId, options.smoothingFactor]);
const cleanup = () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
}
analyserRef.current = null;
};
return { level };
}
+81 -80
View File
@@ -1,80 +1,81 @@
import { useState, useEffect } from 'react';
export interface MicrophoneDevice {
deviceId: string;
label: string;
groupId: string;
}
export function useMicrophoneDevices(enabled: boolean = true) {
const [devices, setDevices] = useState<MicrophoneDevice[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('default');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!enabled) {
return;
}
let mounted = true;
const loadDevices = async () => {
try {
setIsLoading(true);
setError(null);
// Request permission first to get actual device labels
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const allDevices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = allDevices
.filter(device => device.kind === 'audioinput')
.map(device => ({
deviceId: device.deviceId,
label: device.label || `Microphone ${device.deviceId.slice(0, 8)}`,
groupId: device.groupId,
}));
// Stop the permission stream
stream.getTracks().forEach(track => track.stop());
if (mounted) {
setDevices(audioInputs);
if (selectedDeviceId === 'default' && audioInputs.length > 0) {
setSelectedDeviceId(audioInputs[0].deviceId);
}
setIsLoading(false);
}
} catch (err) {
if (mounted) {
const errorMessage = err instanceof Error ? err.message : 'Failed to enumerate audio devices';
setError(errorMessage);
setIsLoading(false);
console.error('Error loading microphone devices:', err);
}
}
};
loadDevices();
const handleDeviceChange = () => {
loadDevices();
};
navigator.mediaDevices.addEventListener('devicechange', handleDeviceChange);
return () => {
mounted = false;
navigator.mediaDevices.removeEventListener('devicechange', handleDeviceChange);
};
}, [enabled]);
return {
devices,
selectedDeviceId,
setSelectedDeviceId,
isLoading,
error,
};
}
import { useEffect, useState } from "react";
export interface MicrophoneDevice {
deviceId: string;
label: string;
groupId: string;
}
export function useMicrophoneDevices(enabled: boolean = true) {
const [devices, setDevices] = useState<MicrophoneDevice[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("default");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!enabled) {
return;
}
let mounted = true;
const loadDevices = async () => {
try {
setIsLoading(true);
setError(null);
// Request permission first to get actual device labels
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const allDevices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = allDevices
.filter((device) => device.kind === "audioinput")
.map((device) => ({
deviceId: device.deviceId,
label: device.label || `Microphone ${device.deviceId.slice(0, 8)}`,
groupId: device.groupId,
}));
// Stop the permission stream
stream.getTracks().forEach((track) => track.stop());
if (mounted) {
setDevices(audioInputs);
if (selectedDeviceId === "default" && audioInputs.length > 0) {
setSelectedDeviceId(audioInputs[0].deviceId);
}
setIsLoading(false);
}
} catch (err) {
if (mounted) {
const errorMessage =
err instanceof Error ? err.message : "Failed to enumerate audio devices";
setError(errorMessage);
setIsLoading(false);
console.error("Error loading microphone devices:", err);
}
}
};
loadDevices();
const handleDeviceChange = () => {
loadDevices();
};
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
return () => {
mounted = false;
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
};
}, [enabled]);
return {
devices,
selectedDeviceId,
setSelectedDeviceId,
isLoading,
error,
};
}
+306 -279
View File
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { fixWebmDuration } from "@fix-webm-duration/fix";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
// Target visually lossless 4K @ 60fps; fall back gracefully when hardware cannot keep up
@@ -39,312 +39,339 @@ const AUDIO_BITRATE_SYSTEM = 192_000;
const MIC_GAIN_BOOST = 1.4;
type UseScreenRecorderReturn = {
recording: boolean;
toggleRecording: () => void;
microphoneEnabled: boolean;
setMicrophoneEnabled: (enabled: boolean) => void;
microphoneDeviceId: string | undefined;
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
systemAudioEnabled: boolean;
setSystemAudioEnabled: (enabled: boolean) => void;
recording: boolean;
toggleRecording: () => void;
microphoneEnabled: boolean;
setMicrophoneEnabled: (enabled: boolean) => void;
microphoneDeviceId: string | undefined;
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
systemAudioEnabled: boolean;
setSystemAudioEnabled: (enabled: boolean) => void;
};
export function useScreenRecorder(): UseScreenRecorderReturn {
const [recording, setRecording] = useState(false);
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
const mediaRecorder = useRef<MediaRecorder | null>(null);
const stream = useRef<MediaStream | null>(null);
const screenStream = useRef<MediaStream | null>(null);
const microphoneStream = useRef<MediaStream | null>(null);
const mixingContext = useRef<AudioContext | null>(null);
const chunks = useRef<Blob[]>([]);
const startTime = useRef<number>(0);
const [recording, setRecording] = useState(false);
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
const mediaRecorder = useRef<MediaRecorder | null>(null);
const stream = useRef<MediaStream | null>(null);
const screenStream = useRef<MediaStream | null>(null);
const microphoneStream = useRef<MediaStream | null>(null);
const mixingContext = useRef<AudioContext | null>(null);
const chunks = useRef<Blob[]>([]);
const startTime = useRef<number>(0);
const selectMimeType = () => {
const preferred = [
"video/webm;codecs=av1",
"video/webm;codecs=h264",
"video/webm;codecs=vp9",
"video/webm;codecs=vp8",
"video/webm"
];
const selectMimeType = () => {
const preferred = [
"video/webm;codecs=av1",
"video/webm;codecs=h264",
"video/webm;codecs=vp9",
"video/webm;codecs=vp8",
"video/webm",
];
return preferred.find(type => MediaRecorder.isTypeSupported(type)) ?? "video/webm";
};
return preferred.find((type) => MediaRecorder.isTypeSupported(type)) ?? "video/webm";
};
const computeBitrate = (width: number, height: number) => {
const pixels = width * height;
const highFrameRateBoost = TARGET_FRAME_RATE >= HIGH_FRAME_RATE_THRESHOLD ? HIGH_FRAME_RATE_BOOST : 1;
const computeBitrate = (width: number, height: number) => {
const pixels = width * height;
const highFrameRateBoost =
TARGET_FRAME_RATE >= HIGH_FRAME_RATE_THRESHOLD ? HIGH_FRAME_RATE_BOOST : 1;
if (pixels >= FOUR_K_PIXELS) {
return Math.round(BITRATE_4K * highFrameRateBoost);
}
if (pixels >= FOUR_K_PIXELS) {
return Math.round(BITRATE_4K * highFrameRateBoost);
}
if (pixels >= QHD_PIXELS) {
return Math.round(BITRATE_QHD * highFrameRateBoost);
}
if (pixels >= QHD_PIXELS) {
return Math.round(BITRATE_QHD * highFrameRateBoost);
}
return Math.round(BITRATE_BASE * highFrameRateBoost);
};
return Math.round(BITRATE_BASE * highFrameRateBoost);
};
const stopRecording = useRef(() => {
if (mediaRecorder.current?.state === "recording") {
if (stream.current) {
stream.current.getTracks().forEach(track => track.stop());
}
if (screenStream.current) {
screenStream.current.getTracks().forEach(track => track.stop());
screenStream.current = null;
}
if (microphoneStream.current) {
microphoneStream.current.getTracks().forEach(track => track.stop());
microphoneStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {});
mixingContext.current = null;
}
mediaRecorder.current.stop();
setRecording(false);
const stopRecording = useRef(() => {
if (mediaRecorder.current?.state === "recording") {
if (stream.current) {
stream.current.getTracks().forEach((track) => track.stop());
}
if (screenStream.current) {
screenStream.current.getTracks().forEach((track) => track.stop());
screenStream.current = null;
}
if (microphoneStream.current) {
microphoneStream.current.getTracks().forEach((track) => track.stop());
microphoneStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {});
mixingContext.current = null;
}
mediaRecorder.current.stop();
setRecording(false);
window.electronAPI?.setRecordingState(false);
}
});
window.electronAPI?.setRecordingState(false);
}
});
useEffect(() => {
let cleanup: (() => void) | undefined;
if (window.electronAPI?.onStopRecordingFromTray) {
cleanup = window.electronAPI.onStopRecordingFromTray(() => {
stopRecording.current();
});
}
useEffect(() => {
let cleanup: (() => void) | undefined;
return () => {
if (cleanup) cleanup();
if (window.electronAPI?.onStopRecordingFromTray) {
cleanup = window.electronAPI.onStopRecordingFromTray(() => {
stopRecording.current();
});
}
if (mediaRecorder.current?.state === "recording") {
mediaRecorder.current.stop();
}
if (stream.current) {
stream.current.getTracks().forEach(track => track.stop());
stream.current = null;
}
if (screenStream.current) {
screenStream.current.getTracks().forEach(track => track.stop());
screenStream.current = null;
}
if (microphoneStream.current) {
microphoneStream.current.getTracks().forEach(track => track.stop());
microphoneStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {});
mixingContext.current = null;
}
};
}, []);
return () => {
if (cleanup) cleanup();
const startRecording = async () => {
try {
const selectedSource = await window.electronAPI.getSelectedSource();
if (!selectedSource) {
alert("Please select a source to record");
return;
}
if (mediaRecorder.current?.state === "recording") {
mediaRecorder.current.stop();
}
if (stream.current) {
stream.current.getTracks().forEach((track) => track.stop());
stream.current = null;
}
if (screenStream.current) {
screenStream.current.getTracks().forEach((track) => track.stop());
screenStream.current = null;
}
if (microphoneStream.current) {
microphoneStream.current.getTracks().forEach((track) => track.stop());
microphoneStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {});
mixingContext.current = null;
}
};
}, []);
let screenMediaStream: MediaStream;
const startRecording = async () => {
try {
const selectedSource = await window.electronAPI.getSelectedSource();
if (!selectedSource) {
alert("Please select a source to record");
return;
}
const videoConstraints = {
mandatory: {
chromeMediaSource: CHROME_MEDIA_SOURCE,
chromeMediaSourceId: selectedSource.id,
maxWidth: TARGET_WIDTH,
maxHeight: TARGET_HEIGHT,
maxFrameRate: TARGET_FRAME_RATE,
minFrameRate: MIN_FRAME_RATE,
},
};
let screenMediaStream: MediaStream;
if (systemAudioEnabled) {
try {
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
audio: { mandatory: { chromeMediaSource: CHROME_MEDIA_SOURCE, chromeMediaSourceId: selectedSource.id } },
video: videoConstraints,
});
} catch (audioErr) {
console.warn('System audio capture failed, falling back to video-only:', audioErr);
toast.error('System audio not available. Recording without system audio.');
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
audio: false,
video: videoConstraints,
});
}
} else {
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
audio: false,
video: videoConstraints,
});
}
screenStream.current = screenMediaStream;
const videoConstraints = {
mandatory: {
chromeMediaSource: CHROME_MEDIA_SOURCE,
chromeMediaSourceId: selectedSource.id,
maxWidth: TARGET_WIDTH,
maxHeight: TARGET_HEIGHT,
maxFrameRate: TARGET_FRAME_RATE,
minFrameRate: MIN_FRAME_RATE,
},
};
// If microphone is enabled, request mic stream
if (microphoneEnabled) {
try {
microphoneStream.current = await navigator.mediaDevices.getUserMedia({
audio: microphoneDeviceId
? {
deviceId: { exact: microphoneDeviceId },
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: false,
});
} catch (audioError) {
console.warn('Failed to get microphone access:', audioError);
toast.error('Microphone access denied. Recording will continue without audio.');
setMicrophoneEnabled(false);
}
}
if (systemAudioEnabled) {
try {
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
audio: {
mandatory: {
chromeMediaSource: CHROME_MEDIA_SOURCE,
chromeMediaSourceId: selectedSource.id,
},
},
video: videoConstraints,
});
} catch (audioErr) {
console.warn("System audio capture failed, falling back to video-only:", audioErr);
toast.error("System audio not available. Recording without system audio.");
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
audio: false,
video: videoConstraints,
});
}
} else {
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
audio: false,
video: videoConstraints,
});
}
screenStream.current = screenMediaStream;
// Combine streams
stream.current = new MediaStream();
const videoTrack = screenMediaStream.getVideoTracks()[0];
if (!videoTrack) {
throw new Error("Video track is not available.");
}
stream.current.addTrack(videoTrack);
// If microphone is enabled, request mic stream
if (microphoneEnabled) {
try {
microphoneStream.current = await navigator.mediaDevices.getUserMedia({
audio: microphoneDeviceId
? {
deviceId: { exact: microphoneDeviceId },
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: false,
});
} catch (audioError) {
console.warn("Failed to get microphone access:", audioError);
toast.error("Microphone access denied. Recording will continue without audio.");
setMicrophoneEnabled(false);
}
}
const systemAudioTrack = screenMediaStream.getAudioTracks()[0];
const micAudioTrack = microphoneStream.current?.getAudioTracks()[0];
// Combine streams
stream.current = new MediaStream();
const videoTrack = screenMediaStream.getVideoTracks()[0];
if (!videoTrack) {
throw new Error("Video track is not available.");
}
stream.current.addTrack(videoTrack);
if (systemAudioTrack && micAudioTrack) {
// Mix system audio + mic using Web Audio API
const ctx = new AudioContext();
mixingContext.current = ctx;
const systemSource = ctx.createMediaStreamSource(new MediaStream([systemAudioTrack]));
const micSource = ctx.createMediaStreamSource(new MediaStream([micAudioTrack]));
const micGain = ctx.createGain();
micGain.gain.value = MIC_GAIN_BOOST;
const destination = ctx.createMediaStreamDestination();
systemSource.connect(destination);
micSource.connect(micGain).connect(destination);
stream.current.addTrack(destination.stream.getAudioTracks()[0]);
} else if (systemAudioTrack) {
stream.current.addTrack(systemAudioTrack);
} else if (micAudioTrack) {
stream.current.addTrack(micAudioTrack);
}
try {
await videoTrack.applyConstraints({
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH },
height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT },
});
} catch (constraintError) {
console.warn("Unable to lock 4K/60fps constraints, using best available track settings.", constraintError);
}
const systemAudioTrack = screenMediaStream.getAudioTracks()[0];
const micAudioTrack = microphoneStream.current?.getAudioTracks()[0];
let { width = DEFAULT_WIDTH, height = DEFAULT_HEIGHT, frameRate = TARGET_FRAME_RATE } = videoTrack.getSettings();
// Ensure dimensions are divisible by 2 for VP9/AV1 codec compatibility
width = Math.floor(width / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
height = Math.floor(height / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
const videoBitsPerSecond = computeBitrate(width, height);
const mimeType = selectMimeType();
if (systemAudioTrack && micAudioTrack) {
// Mix system audio + mic using Web Audio API
const ctx = new AudioContext();
mixingContext.current = ctx;
const systemSource = ctx.createMediaStreamSource(new MediaStream([systemAudioTrack]));
const micSource = ctx.createMediaStreamSource(new MediaStream([micAudioTrack]));
const micGain = ctx.createGain();
micGain.gain.value = MIC_GAIN_BOOST;
const destination = ctx.createMediaStreamDestination();
systemSource.connect(destination);
micSource.connect(micGain).connect(destination);
stream.current.addTrack(destination.stream.getAudioTracks()[0]);
} else if (systemAudioTrack) {
stream.current.addTrack(systemAudioTrack);
} else if (micAudioTrack) {
stream.current.addTrack(micAudioTrack);
}
try {
await videoTrack.applyConstraints({
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH },
height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT },
});
} catch (constraintError) {
console.warn(
"Unable to lock 4K/60fps constraints, using best available track settings.",
constraintError,
);
}
console.log(
`Recording at ${width}x${height} @ ${frameRate ?? TARGET_FRAME_RATE}fps using ${mimeType} / ${Math.round(
videoBitsPerSecond / BITS_PER_MEGABIT
)} Mbps`
);
const hasAudio = stream.current.getAudioTracks().length > 0;
let {
width = DEFAULT_WIDTH,
height = DEFAULT_HEIGHT,
frameRate = TARGET_FRAME_RATE,
} = videoTrack.getSettings();
chunks.current = [];
const recorder = new MediaRecorder(stream.current, {
mimeType,
videoBitsPerSecond,
...(hasAudio ? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE } : {}),
});
mediaRecorder.current = recorder;
recorder.ondataavailable = e => {
if (e.data && e.data.size > 0) chunks.current.push(e.data);
};
recorder.onstop = async () => {
stream.current = null;
if (chunks.current.length === 0) return;
const duration = Date.now() - startTime.current;
const recordedChunks = chunks.current;
const buggyBlob = new Blob(recordedChunks, { type: mimeType });
// Clear chunks early to free memory immediately after blob creation
chunks.current = [];
const timestamp = Date.now();
const videoFileName = `${RECORDING_FILE_PREFIX}${timestamp}${VIDEO_FILE_EXTENSION}`;
// Ensure dimensions are divisible by 2 for VP9/AV1 codec compatibility
width = Math.floor(width / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
height = Math.floor(height / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
try {
const videoBlob = await fixWebmDuration(buggyBlob, duration);
const arrayBuffer = await videoBlob.arrayBuffer();
const videoResult = await window.electronAPI.storeRecordedVideo(arrayBuffer, videoFileName);
if (!videoResult.success) {
console.error('Failed to store video:', videoResult.message);
return;
}
const videoBitsPerSecond = computeBitrate(width, height);
const mimeType = selectMimeType();
if (videoResult.path) {
await window.electronAPI.setCurrentVideoPath(videoResult.path);
}
console.log(
`Recording at ${width}x${height} @ ${frameRate ?? TARGET_FRAME_RATE}fps using ${mimeType} / ${Math.round(
videoBitsPerSecond / BITS_PER_MEGABIT,
)} Mbps`,
);
await window.electronAPI.switchToEditor();
} catch (error) {
console.error('Error saving recording:', error);
}
};
recorder.onerror = () => setRecording(false);
recorder.start(RECORDER_TIMESLICE_MS);
startTime.current = Date.now();
setRecording(true);
window.electronAPI?.setRecordingState(true);
} catch (error) {
console.error('Failed to start recording:', error);
const errorMsg = error instanceof Error ? error.message : 'Failed to start recording';
if (errorMsg.includes('Permission denied') || errorMsg.includes('NotAllowedError')) {
toast.error('Recording permission denied. Please allow screen recording.');
} else {
toast.error(errorMsg);
}
setRecording(false);
if (stream.current) {
stream.current.getTracks().forEach(track => track.stop());
stream.current = null;
}
if (screenStream.current) {
screenStream.current.getTracks().forEach(track => track.stop());
screenStream.current = null;
}
if (microphoneStream.current) {
microphoneStream.current.getTracks().forEach(track => track.stop());
microphoneStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {});
mixingContext.current = null;
}
}
};
const hasAudio = stream.current.getAudioTracks().length > 0;
const toggleRecording = () => {
recording ? stopRecording.current() : startRecording();
};
chunks.current = [];
const recorder = new MediaRecorder(stream.current, {
mimeType,
videoBitsPerSecond,
...(hasAudio
? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE }
: {}),
});
mediaRecorder.current = recorder;
recorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) chunks.current.push(e.data);
};
recorder.onstop = async () => {
stream.current = null;
if (chunks.current.length === 0) return;
const duration = Date.now() - startTime.current;
const recordedChunks = chunks.current;
const buggyBlob = new Blob(recordedChunks, { type: mimeType });
// Clear chunks early to free memory immediately after blob creation
chunks.current = [];
const timestamp = Date.now();
const videoFileName = `${RECORDING_FILE_PREFIX}${timestamp}${VIDEO_FILE_EXTENSION}`;
return { recording, toggleRecording, microphoneEnabled, setMicrophoneEnabled, microphoneDeviceId, setMicrophoneDeviceId, systemAudioEnabled, setSystemAudioEnabled };
try {
const videoBlob = await fixWebmDuration(buggyBlob, duration);
const arrayBuffer = await videoBlob.arrayBuffer();
const videoResult = await window.electronAPI.storeRecordedVideo(
arrayBuffer,
videoFileName,
);
if (!videoResult.success) {
console.error("Failed to store video:", videoResult.message);
return;
}
if (videoResult.path) {
await window.electronAPI.setCurrentVideoPath(videoResult.path);
}
await window.electronAPI.switchToEditor();
} catch (error) {
console.error("Error saving recording:", error);
}
};
recorder.onerror = () => setRecording(false);
recorder.start(RECORDER_TIMESLICE_MS);
startTime.current = Date.now();
setRecording(true);
window.electronAPI?.setRecordingState(true);
} catch (error) {
console.error("Failed to start recording:", error);
const errorMsg = error instanceof Error ? error.message : "Failed to start recording";
if (errorMsg.includes("Permission denied") || errorMsg.includes("NotAllowedError")) {
toast.error("Recording permission denied. Please allow screen recording.");
} else {
toast.error(errorMsg);
}
setRecording(false);
if (stream.current) {
stream.current.getTracks().forEach((track) => track.stop());
stream.current = null;
}
if (screenStream.current) {
screenStream.current.getTracks().forEach((track) => track.stop());
screenStream.current = null;
}
if (microphoneStream.current) {
microphoneStream.current.getTracks().forEach((track) => track.stop());
microphoneStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {});
mixingContext.current = null;
}
}
};
const toggleRecording = () => {
recording ? stopRecording.current() : startRecording();
};
return {
recording,
toggleRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
setMicrophoneDeviceId,
systemAudioEnabled,
setSystemAudioEnabled,
};
}
+142 -141
View File
@@ -1,141 +1,142 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--primary: 24 9.8% 10%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--ring: 20 14.3% 4.1%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 20 14.3% 4.1%;
--foreground: 60 9.1% 97.8%;
--card: 20 14.3% 4.1%;
--card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%;
--primary: 60 9.1% 97.8%;
--primary-foreground: 24 9.8% 10%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
--ring: 24 5.7% 82.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
-webkit-user-select: none;
user-select: none;
}
}
/* Smooth timeline cursor animations */
@layer utilities {
.timeline-cursor-smooth {
will-change: transform;
transition: left 33ms linear, right 33ms linear;
}
/* Hidden scrollbar - still scrollable but invisible */
.custom-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.custom-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
/* Smooth playback scrubber */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
cursor: pointer;
box-shadow: 0 2px 8px rgba(255,255,255,0.32);
border: 2px solid #fff;
transition: all 0.08s ease-out;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: #fff;
transform: scale(1.15);
box-shadow: 0 3px 10px rgba(255,255,255,0.5);
border-color: #34B27B;
}
input[type="range"]::-webkit-slider-thumb:active {
transform: scale(1.25);
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(255,255,255,0.32);
transition: all 0.08s ease-out;
}
input[type="range"]::-moz-range-thumb:hover {
background: #fff;
transform: scale(1.15);
box-shadow: 0 3px 10px rgba(255,255,255,0.5);
border-color: #34B27B;
}
input[type="range"]::-moz-range-thumb:active {
transform: scale(1.25);
}
}
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--primary: 24 9.8% 10%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--ring: 20 14.3% 4.1%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 20 14.3% 4.1%;
--foreground: 60 9.1% 97.8%;
--card: 20 14.3% 4.1%;
--card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%;
--primary: 60 9.1% 97.8%;
--primary-foreground: 24 9.8% 10%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
--ring: 24 5.7% 82.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
-webkit-user-select: none;
user-select: none;
}
}
/* Smooth timeline cursor animations */
@layer utilities {
.timeline-cursor-smooth {
will-change: transform;
transition:
left 33ms linear,
right 33ms linear;
}
/* Hidden scrollbar - still scrollable but invisible */
.custom-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.custom-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
/* Smooth playback scrubber */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
cursor: pointer;
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.32);
border: 2px solid #fff;
transition: all 0.08s ease-out;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: #fff;
transform: scale(1.15);
box-shadow: 0 3px 10px rgba(255, 255, 255, 0.5);
border-color: #34b27b;
}
input[type="range"]::-webkit-slider-thumb:active {
transform: scale(1.25);
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.32);
transition: all 0.08s ease-out;
}
input[type="range"]::-moz-range-thumb:hover {
background: #fff;
transform: scale(1.15);
box-shadow: 0 3px 10px rgba(255, 255, 255, 0.5);
border-color: #34b27b;
}
input[type="range"]::-moz-range-thumb:active {
transform: scale(1.25);
}
}
+26 -19
View File
@@ -1,25 +1,32 @@
export async function getAssetPath(relativePath: string): Promise<string> {
try {
if (typeof window !== 'undefined') {
// If running in a dev server (http/https), prefer the web-served path
if (window.location && window.location.protocol && window.location.protocol.startsWith('http')) {
return `/${relativePath.replace(/^\//, '')}`
}
try {
if (typeof window !== "undefined") {
// If running in a dev server (http/https), prefer the web-served path
if (
window.location &&
window.location.protocol &&
window.location.protocol.startsWith("http")
) {
return `/${relativePath.replace(/^\//, "")}`;
}
if ((window as any).electronAPI && typeof (window as any).electronAPI.getAssetBasePath === 'function') {
const base = await (window as any).electronAPI.getAssetBasePath()
if (base) {
const normalized = base.replace(/\\/g, '/')
return `file://${normalized}/${relativePath}`
}
}
}
} catch (err) {
// ignore and use fallback
}
if (
(window as any).electronAPI &&
typeof (window as any).electronAPI.getAssetBasePath === "function"
) {
const base = await (window as any).electronAPI.getAssetBasePath();
if (base) {
const normalized = base.replace(/\\/g, "/");
return `file://${normalized}/${relativePath}`;
}
}
}
} catch {
// ignore and use fallback
}
// Fallback for web/dev server: public/wallpapers are served at '/wallpapers/...'
return `/${relativePath.replace(/^\//, '')}`
// Fallback for web/dev server: public/wallpapers are served at '/wallpapers/...'
return `/${relativePath.replace(/^\//, "")}`;
}
export default getAssetPath;
+181 -178
View File
@@ -1,178 +1,181 @@
// Google Fonts loading and management utility
export interface CustomFont {
id: string;
name: string; // Display name
fontFamily: string; // CSS font-family value
importUrl: string; // Google Fonts @import URL
}
const STORAGE_KEY = 'openscreen_custom_fonts';
const loadedFonts = new Set<string>();
// Load custom fonts from localStorage
export function getCustomFonts(): CustomFont[] {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error('Failed to load custom fonts from storage:', error);
return [];
}
}
// Save custom fonts to localStorage
export function saveCustomFonts(fonts: CustomFont[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(fonts));
} catch (error) {
console.error('Failed to save custom fonts to storage:', error);
}
}
// Add a new custom font (throws error if font fails to load)
export async function addCustomFont(font: CustomFont): Promise<CustomFont[]> {
const fonts = getCustomFonts();
const exists = fonts.some(f => f.id === font.id || f.fontFamily === font.fontFamily);
if (exists) {
return fonts;
}
// Try to load the font first - this will throw if it fails
await loadFont(font);
// Only add to storage if font loaded successfully
fonts.push(font);
saveCustomFonts(fonts);
return fonts;
}
// Remove a custom font
export function removeCustomFont(fontId: string): CustomFont[] {
const fonts = getCustomFonts();
const filtered = fonts.filter(f => f.id !== fontId);
saveCustomFonts(filtered);
// Remove the style element
const styleEl = document.getElementById(`custom-font-${fontId}`);
if (styleEl) {
styleEl.remove();
}
loadedFonts.delete(fontId);
return filtered;
}
// Load a Google Font into the document
export function loadFont(font: CustomFont): Promise<void> {
return new Promise((resolve, reject) => {
// Skip if already loaded
if (loadedFonts.has(font.id)) {
resolve();
return;
}
try {
const styleId = `custom-font-${font.id}`;
// Remove existing style if present
const existing = document.getElementById(styleId);
if (existing) {
existing.remove();
}
// Create style element with @import
const style = document.createElement('style');
style.id = styleId;
style.textContent = `@import url('${font.importUrl}');`;
document.head.appendChild(style);
// Wait for font to load
waitForFont(font.fontFamily)
.then(() => {
loadedFonts.add(font.id);
resolve();
})
.catch(reject);
} catch (error) {
console.error('Failed to load font:', font, error);
reject(error);
}
});
}
// Wait for a font to be available and verify it loaded
function waitForFont(fontFamily: string, timeout = 5000): Promise<void> {
return new Promise((resolve, reject) => {
// Use CSS Font Loading API if available
if ('fonts' in document) {
Promise.race([
document.fonts.load(`16px "${fontFamily}"`),
new Promise((_, rej) => setTimeout(() => rej(new Error('Font load timeout')), timeout))
])
.then(() => {
// Verify the font actually loaded by checking if it's available
const isAvailable = document.fonts.check(`16px "${fontFamily}"`);
if (isAvailable) {
resolve();
} else {
reject(new Error(`Font "${fontFamily}" failed to load`));
}
})
.catch((error) => {
reject(error);
});
} else {
// Fallback for browsers without Font Loading API
// Wait a bit and hope for the best
setTimeout(() => resolve(), 1000);
}
});
}
// Load all stored custom fonts on app initialization
export function loadAllCustomFonts(): Promise<void[]> {
const fonts = getCustomFonts();
return Promise.all(fonts.map(font => loadFont(font).catch(err => {
console.error('Failed to load custom font:', font.name, err);
})));
}
// Generate a unique ID for a font
export function generateFontId(name: string): string {
return `${name.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}`;
}
// Parse Google Fonts @import URL to extract font family name
export function parseFontFamilyFromImport(importUrl: string): string | null {
try {
// Extract from URL like: https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap
const url = new URL(importUrl);
const familyParam = url.searchParams.get('family');
if (familyParam) {
// Remove weight/style info: "Roboto:wght@400;700" -> "Roboto"
const fontName = familyParam.split(':')[0];
// Replace + with spaces: "Open+Sans" -> "Open Sans"
return fontName.replace(/\+/g, ' ');
}
return null;
} catch (error) {
console.error('Failed to parse font family from import URL:', error);
return null;
}
}
// Validate if a string looks like a Google Fonts import URL
export function isValidGoogleFontsUrl(url: string): boolean {
try {
const urlObj = new URL(url);
return urlObj.hostname === 'fonts.googleapis.com' && urlObj.searchParams.has('family');
} catch {
return false;
}
}
// Google Fonts loading and management utility
export interface CustomFont {
id: string;
name: string; // Display name
fontFamily: string; // CSS font-family value
importUrl: string; // Google Fonts @import URL
}
const STORAGE_KEY = "openscreen_custom_fonts";
const loadedFonts = new Set<string>();
// Load custom fonts from localStorage
export function getCustomFonts(): CustomFont[] {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error("Failed to load custom fonts from storage:", error);
return [];
}
}
// Save custom fonts to localStorage
export function saveCustomFonts(fonts: CustomFont[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(fonts));
} catch (error) {
console.error("Failed to save custom fonts to storage:", error);
}
}
// Add a new custom font (throws error if font fails to load)
export async function addCustomFont(font: CustomFont): Promise<CustomFont[]> {
const fonts = getCustomFonts();
const exists = fonts.some((f) => f.id === font.id || f.fontFamily === font.fontFamily);
if (exists) {
return fonts;
}
// Try to load the font first - this will throw if it fails
await loadFont(font);
// Only add to storage if font loaded successfully
fonts.push(font);
saveCustomFonts(fonts);
return fonts;
}
// Remove a custom font
export function removeCustomFont(fontId: string): CustomFont[] {
const fonts = getCustomFonts();
const filtered = fonts.filter((f) => f.id !== fontId);
saveCustomFonts(filtered);
// Remove the style element
const styleEl = document.getElementById(`custom-font-${fontId}`);
if (styleEl) {
styleEl.remove();
}
loadedFonts.delete(fontId);
return filtered;
}
// Load a Google Font into the document
export function loadFont(font: CustomFont): Promise<void> {
return new Promise((resolve, reject) => {
// Skip if already loaded
if (loadedFonts.has(font.id)) {
resolve();
return;
}
try {
const styleId = `custom-font-${font.id}`;
// Remove existing style if present
const existing = document.getElementById(styleId);
if (existing) {
existing.remove();
}
// Create style element with @import
const style = document.createElement("style");
style.id = styleId;
style.textContent = `@import url('${font.importUrl}');`;
document.head.appendChild(style);
// Wait for font to load
waitForFont(font.fontFamily)
.then(() => {
loadedFonts.add(font.id);
resolve();
})
.catch(reject);
} catch (error) {
console.error("Failed to load font:", font, error);
reject(error);
}
});
}
// Wait for a font to be available and verify it loaded
function waitForFont(fontFamily: string, timeout = 5000): Promise<void> {
return new Promise((resolve, reject) => {
// Use CSS Font Loading API if available
if ("fonts" in document) {
Promise.race([
document.fonts.load(`16px "${fontFamily}"`),
new Promise((_, rej) => setTimeout(() => rej(new Error("Font load timeout")), timeout)),
])
.then(() => {
// Verify the font actually loaded by checking if it's available
const isAvailable = document.fonts.check(`16px "${fontFamily}"`);
if (isAvailable) {
resolve();
} else {
reject(new Error(`Font "${fontFamily}" failed to load`));
}
})
.catch((error) => {
reject(error);
});
} else {
// Fallback for browsers without Font Loading API
// Wait a bit and hope for the best
setTimeout(() => resolve(), 1000);
}
});
}
// Load all stored custom fonts on app initialization
export function loadAllCustomFonts(): Promise<void[]> {
const fonts = getCustomFonts();
return Promise.all(
fonts.map((font) =>
loadFont(font).catch((err) => {
console.error("Failed to load custom font:", font.name, err);
}),
),
);
}
// Generate a unique ID for a font
export function generateFontId(name: string): string {
return `${name.toLowerCase().replace(/\s+/g, "-")}-${Date.now()}`;
}
// Parse Google Fonts @import URL to extract font family name
export function parseFontFamilyFromImport(importUrl: string): string | null {
try {
// Extract from URL like: https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap
const url = new URL(importUrl);
const familyParam = url.searchParams.get("family");
if (familyParam) {
// Remove weight/style info: "Roboto:wght@400;700" -> "Roboto"
const fontName = familyParam.split(":")[0];
// Replace + with spaces: "Open+Sans" -> "Open Sans"
return fontName.replace(/\+/g, " ");
}
return null;
} catch (error) {
console.error("Failed to parse font family from import URL:", error);
return null;
}
}
// Validate if a string looks like a Google Fonts import URL
export function isValidGoogleFontsUrl(url: string): boolean {
try {
const urlObj = new URL(url);
return urlObj.hostname === "fonts.googleapis.com" && urlObj.searchParams.has("family");
} catch {
return false;
}
}
+280 -311
View File
@@ -1,340 +1,309 @@
import type { AnnotationRegion, ArrowDirection } from '@/components/video-editor/types';
import type { AnnotationRegion, ArrowDirection } from "@/components/video-editor/types";
// SVG path data for each arrow direction
const ARROW_PATHS: Record<ArrowDirection, string[]> = {
'up': [
'M 50 20 L 50 80',
'M 50 20 L 35 35',
'M 50 20 L 65 35',
],
'down': [
'M 50 20 L 50 80',
'M 50 80 L 35 65',
'M 50 80 L 65 65',
],
'left': [
'M 80 50 L 20 50',
'M 20 50 L 35 35',
'M 20 50 L 35 65',
],
'right': [
'M 20 50 L 80 50',
'M 80 50 L 65 35',
'M 80 50 L 65 65',
],
'up-right': [
'M 25 75 L 75 25',
'M 75 25 L 60 30',
'M 75 25 L 70 40',
],
'up-left': [
'M 75 75 L 25 25',
'M 25 25 L 40 30',
'M 25 25 L 30 40',
],
'down-right': [
'M 25 25 L 75 75',
'M 75 75 L 70 60',
'M 75 75 L 60 70',
],
'down-left': [
'M 75 25 L 25 75',
'M 25 75 L 30 60',
'M 25 75 L 40 70',
],
up: ["M 50 20 L 50 80", "M 50 20 L 35 35", "M 50 20 L 65 35"],
down: ["M 50 20 L 50 80", "M 50 80 L 35 65", "M 50 80 L 65 65"],
left: ["M 80 50 L 20 50", "M 20 50 L 35 35", "M 20 50 L 35 65"],
right: ["M 20 50 L 80 50", "M 80 50 L 65 35", "M 80 50 L 65 65"],
"up-right": ["M 25 75 L 75 25", "M 75 25 L 60 30", "M 75 25 L 70 40"],
"up-left": ["M 75 75 L 25 25", "M 25 25 L 40 30", "M 25 25 L 30 40"],
"down-right": ["M 25 25 L 75 75", "M 75 75 L 70 60", "M 75 75 L 60 70"],
"down-left": ["M 75 25 L 25 75", "M 25 75 L 30 60", "M 25 75 L 40 70"],
};
function parseSvgPath(pathString: string, scaleX: number, scaleY: number): Array<{ cmd: string; args: number[] }> {
const commands: Array<{ cmd: string; args: number[] }> = [];
const parts = pathString.trim().split(/\s+/);
let i = 0;
while (i < parts.length) {
const cmd = parts[i];
if (cmd === 'M' || cmd === 'L') {
const x = parseFloat(parts[i + 1]) * scaleX;
const y = parseFloat(parts[i + 2]) * scaleY;
commands.push({ cmd, args: [x, y] });
i += 3;
} else {
i++;
}
}
return commands;
function parseSvgPath(
pathString: string,
scaleX: number,
scaleY: number,
): Array<{ cmd: string; args: number[] }> {
const commands: Array<{ cmd: string; args: number[] }> = [];
const parts = pathString.trim().split(/\s+/);
let i = 0;
while (i < parts.length) {
const cmd = parts[i];
if (cmd === "M" || cmd === "L") {
const x = parseFloat(parts[i + 1]) * scaleX;
const y = parseFloat(parts[i + 2]) * scaleY;
commands.push({ cmd, args: [x, y] });
i += 3;
} else {
i++;
}
}
return commands;
}
function renderArrow(
ctx: CanvasRenderingContext2D,
direction: ArrowDirection,
color: string,
strokeWidth: number,
x: number,
y: number,
width: number,
height: number,
_scaleFactor: number
ctx: CanvasRenderingContext2D,
direction: ArrowDirection,
color: string,
strokeWidth: number,
x: number,
y: number,
width: number,
height: number,
_scaleFactor: number,
) {
const paths = ARROW_PATHS[direction];
if (!paths) return;
const paths = ARROW_PATHS[direction];
if (!paths) return;
ctx.save();
ctx.translate(x, y);
const padding = 8 * _scaleFactor;
const availableWidth = Math.max(0, width - padding * 2);
const availableHeight = Math.max(0, height - padding * 2);
ctx.save();
ctx.translate(x, y);
const scale = Math.min(availableWidth / 100, availableHeight / 100);
const offsetX = padding + (availableWidth - 100 * scale) / 2;
const offsetY = padding + (availableHeight - 100 * scale) / 2;
// Apply centering offset
ctx.translate(offsetX, offsetY);
// Apply shadow filter
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
ctx.shadowBlur = 8 * scale;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 4 * scale;
ctx.strokeStyle = color;
ctx.lineWidth = strokeWidth * scale;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Draw all paths as a single shape to avoid overlapping shadows/strokes
ctx.beginPath();
for (const pathString of paths) {
const commands = parseSvgPath(pathString, scale, scale);
const padding = 8 * _scaleFactor;
const availableWidth = Math.max(0, width - padding * 2);
const availableHeight = Math.max(0, height - padding * 2);
for (const { cmd, args } of commands) {
if (cmd === 'M') {
ctx.moveTo(args[0], args[1]);
} else if (cmd === 'L') {
ctx.lineTo(args[0], args[1]);
}
}
}
ctx.stroke();
ctx.restore();
const scale = Math.min(availableWidth / 100, availableHeight / 100);
const offsetX = padding + (availableWidth - 100 * scale) / 2;
const offsetY = padding + (availableHeight - 100 * scale) / 2;
// Apply centering offset
ctx.translate(offsetX, offsetY);
// Apply shadow filter
ctx.shadowColor = "rgba(0, 0, 0, 0.3)";
ctx.shadowBlur = 8 * scale;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 4 * scale;
ctx.strokeStyle = color;
ctx.lineWidth = strokeWidth * scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
// Draw all paths as a single shape to avoid overlapping shadows/strokes
ctx.beginPath();
for (const pathString of paths) {
const commands = parseSvgPath(pathString, scale, scale);
for (const { cmd, args } of commands) {
if (cmd === "M") {
ctx.moveTo(args[0], args[1]);
} else if (cmd === "L") {
ctx.lineTo(args[0], args[1]);
}
}
}
ctx.stroke();
ctx.restore();
}
function renderText(
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
x: number,
y: number,
width: number,
height: number,
scaleFactor: number
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
x: number,
y: number,
width: number,
height: number,
scaleFactor: number,
) {
const style = annotation.style;
ctx.save();
const style = annotation.style;
// Clip text to annotation box bounds (matches editor's overflow: hidden)
ctx.beginPath();
ctx.rect(x, y, width, height);
ctx.clip();
ctx.save();
const fontWeight = style.fontWeight === 'bold' ? 'bold' : 'normal';
const fontStyle = style.fontStyle === 'italic' ? 'italic' : 'normal';
const scaledFontSize = style.fontSize * scaleFactor;
ctx.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${style.fontFamily}`;
ctx.textBaseline = 'middle';
const containerPadding = 8 * scaleFactor;
let textX = x;
let textY = y + height / 2;
if (style.textAlign === 'center') {
textX = x + width / 2;
ctx.textAlign = 'center';
} else if (style.textAlign === 'right') {
textX = x + width - containerPadding;
ctx.textAlign = 'right';
} else {
textX = x + containerPadding;
ctx.textAlign = 'left';
}
const availableWidth = width - containerPadding * 2;
const rawLines = annotation.content.split('\n');
const lines: string[] = [];
for (const rawLine of rawLines) {
if (!rawLine) {
lines.push('');
continue;
}
const words = rawLine.split(/(\s+)/);
let current = '';
for (const word of words) {
const test = current + word;
if (current && ctx.measureText(test).width > availableWidth) {
lines.push(current);
current = word.trimStart();
} else {
current = test;
}
}
if (current) lines.push(current);
}
const lineHeight = scaledFontSize * 1.4;
// Clip text to annotation box bounds (matches editor's overflow: hidden)
ctx.beginPath();
ctx.rect(x, y, width, height);
ctx.clip();
const startY = textY - ((lines.length - 1) * lineHeight) / 2;
lines.forEach((line, index) => {
const currentY = startY + index * lineHeight;
if (style.backgroundColor && style.backgroundColor !== 'transparent') {
const metrics = ctx.measureText(line);
const verticalPadding = scaledFontSize * 0.1;
const horizontalPadding = scaledFontSize * 0.2;
const borderRadius = 4 * scaleFactor;
let bgX = textX - horizontalPadding;
const bgWidth = metrics.width + horizontalPadding * 2;
const contentHeight = scaledFontSize * 1.4;
const bgHeight = contentHeight + verticalPadding * 2;
const bgY = currentY - bgHeight / 2;
if (style.textAlign === 'center') {
bgX = textX - bgWidth / 2;
} else if (style.textAlign === 'right') {
bgX = textX - bgWidth;
}
ctx.fillStyle = style.backgroundColor;
ctx.beginPath();
ctx.roundRect(bgX, bgY, bgWidth, bgHeight, borderRadius);
ctx.fill();
}
ctx.fillStyle = style.color;
ctx.fillText(line, textX, currentY);
if (style.textDecoration === 'underline') {
const metrics = ctx.measureText(line);
let underlineX = textX;
const underlineY = currentY + scaledFontSize * 0.15;
if (style.textAlign === 'center') {
underlineX = textX - metrics.width / 2;
} else if (style.textAlign === 'right') {
underlineX = textX - metrics.width;
}
ctx.strokeStyle = style.color;
ctx.lineWidth = Math.max(1, scaledFontSize / 16);
ctx.beginPath();
ctx.moveTo(underlineX, underlineY);
ctx.lineTo(underlineX + metrics.width, underlineY);
ctx.stroke();
}
});
ctx.restore();
const fontWeight = style.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = style.fontStyle === "italic" ? "italic" : "normal";
const scaledFontSize = style.fontSize * scaleFactor;
ctx.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${style.fontFamily}`;
ctx.textBaseline = "middle";
const containerPadding = 8 * scaleFactor;
let textX = x;
let textY = y + height / 2;
if (style.textAlign === "center") {
textX = x + width / 2;
ctx.textAlign = "center";
} else if (style.textAlign === "right") {
textX = x + width - containerPadding;
ctx.textAlign = "right";
} else {
textX = x + containerPadding;
ctx.textAlign = "left";
}
const availableWidth = width - containerPadding * 2;
const rawLines = annotation.content.split("\n");
const lines: string[] = [];
for (const rawLine of rawLines) {
if (!rawLine) {
lines.push("");
continue;
}
const words = rawLine.split(/(\s+)/);
let current = "";
for (const word of words) {
const test = current + word;
if (current && ctx.measureText(test).width > availableWidth) {
lines.push(current);
current = word.trimStart();
} else {
current = test;
}
}
if (current) lines.push(current);
}
const lineHeight = scaledFontSize * 1.4;
const startY = textY - ((lines.length - 1) * lineHeight) / 2;
lines.forEach((line, index) => {
const currentY = startY + index * lineHeight;
if (style.backgroundColor && style.backgroundColor !== "transparent") {
const metrics = ctx.measureText(line);
const verticalPadding = scaledFontSize * 0.1;
const horizontalPadding = scaledFontSize * 0.2;
const borderRadius = 4 * scaleFactor;
let bgX = textX - horizontalPadding;
const bgWidth = metrics.width + horizontalPadding * 2;
const contentHeight = scaledFontSize * 1.4;
const bgHeight = contentHeight + verticalPadding * 2;
const bgY = currentY - bgHeight / 2;
if (style.textAlign === "center") {
bgX = textX - bgWidth / 2;
} else if (style.textAlign === "right") {
bgX = textX - bgWidth;
}
ctx.fillStyle = style.backgroundColor;
ctx.beginPath();
ctx.roundRect(bgX, bgY, bgWidth, bgHeight, borderRadius);
ctx.fill();
}
ctx.fillStyle = style.color;
ctx.fillText(line, textX, currentY);
if (style.textDecoration === "underline") {
const metrics = ctx.measureText(line);
let underlineX = textX;
const underlineY = currentY + scaledFontSize * 0.15;
if (style.textAlign === "center") {
underlineX = textX - metrics.width / 2;
} else if (style.textAlign === "right") {
underlineX = textX - metrics.width;
}
ctx.strokeStyle = style.color;
ctx.lineWidth = Math.max(1, scaledFontSize / 16);
ctx.beginPath();
ctx.moveTo(underlineX, underlineY);
ctx.lineTo(underlineX + metrics.width, underlineY);
ctx.stroke();
}
});
ctx.restore();
}
async function renderImage(
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
x: number,
y: number,
width: number,
height: number
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
x: number,
y: number,
width: number,
height: number,
): Promise<void> {
if (!annotation.content || !annotation.content.startsWith('data:image')) {
return;
}
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
// Preserve aspect ratio - contain the image within the bounds
const imgAspect = img.width / img.height;
const boxAspect = width / height;
let drawWidth = width;
let drawHeight = height;
let drawX = x;
let drawY = y;
if (imgAspect > boxAspect) {
if (!annotation.content || !annotation.content.startsWith("data:image")) {
return;
}
drawHeight = width / imgAspect;
drawY = y + (height - drawHeight) / 2;
} else {
drawWidth = height * imgAspect;
drawX = x + (width - drawWidth) / 2;
}
ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
resolve();
};
img.onerror = () => {
console.error('[AnnotationRenderer] Failed to load image annotation');
resolve();
};
img.src = annotation.content;
});
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
// Preserve aspect ratio - contain the image within the bounds
const imgAspect = img.width / img.height;
const boxAspect = width / height;
let drawWidth = width;
let drawHeight = height;
let drawX = x;
let drawY = y;
if (imgAspect > boxAspect) {
drawHeight = width / imgAspect;
drawY = y + (height - drawHeight) / 2;
} else {
drawWidth = height * imgAspect;
drawX = x + (width - drawWidth) / 2;
}
ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
resolve();
};
img.onerror = () => {
console.error("[AnnotationRenderer] Failed to load image annotation");
resolve();
};
img.src = annotation.content;
});
}
export async function renderAnnotations(
ctx: CanvasRenderingContext2D,
annotations: AnnotationRegion[],
canvasWidth: number,
canvasHeight: number,
currentTimeMs: number,
scaleFactor: number = 1.0
ctx: CanvasRenderingContext2D,
annotations: AnnotationRegion[],
canvasWidth: number,
canvasHeight: number,
currentTimeMs: number,
scaleFactor: number = 1.0,
): Promise<void> {
// Filter active annotations at current time
const activeAnnotations = annotations.filter(
(ann) => currentTimeMs >= ann.startMs && currentTimeMs <= ann.endMs
);
// Sort by z-index (lower first, so higher z-index draws on top)
const sortedAnnotations = [...activeAnnotations].sort((a, b) => a.zIndex - b.zIndex);
for (const annotation of sortedAnnotations) {
const x = (annotation.position.x / 100) * canvasWidth;
const y = (annotation.position.y / 100) * canvasHeight;
const width = (annotation.size.width / 100) * canvasWidth;
const height = (annotation.size.height / 100) * canvasHeight;
switch (annotation.type) {
case 'text':
renderText(ctx, annotation, x, y, width, height, scaleFactor);
break;
case 'image':
await renderImage(ctx, annotation, x, y, width, height);
break;
case 'figure':
if (annotation.figureData) {
renderArrow(
ctx,
annotation.figureData.arrowDirection,
annotation.figureData.color,
annotation.figureData.strokeWidth,
x,
y,
width,
height,
scaleFactor
);
}
break;
}
}
// Filter active annotations at current time
const activeAnnotations = annotations.filter(
(ann) => currentTimeMs >= ann.startMs && currentTimeMs <= ann.endMs,
);
// Sort by z-index (lower first, so higher z-index draws on top)
const sortedAnnotations = [...activeAnnotations].sort((a, b) => a.zIndex - b.zIndex);
for (const annotation of sortedAnnotations) {
const x = (annotation.position.x / 100) * canvasWidth;
const y = (annotation.position.y / 100) * canvasHeight;
const width = (annotation.size.width / 100) * canvasWidth;
const height = (annotation.size.height / 100) * canvasHeight;
switch (annotation.type) {
case "text":
renderText(ctx, annotation, x, y, width, height, scaleFactor);
break;
case "image":
await renderImage(ctx, annotation, x, y, width, height);
break;
case "figure":
if (annotation.figureData) {
renderArrow(
ctx,
annotation.figureData.arrowDirection,
annotation.figureData.color,
annotation.figureData.strokeWidth,
x,
y,
width,
height,
scaleFactor,
);
}
break;
}
}
}
+169 -173
View File
@@ -1,173 +1,169 @@
import type { WebDemuxer } from 'web-demuxer';
import type { TrimRegion } from '@/components/video-editor/types';
import type { VideoMuxer } from './muxer';
const AUDIO_BITRATE = 128_000;
const DECODE_BACKPRESSURE_LIMIT = 20;
export class AudioProcessor {
private cancelled = false;
async process(
demuxer: WebDemuxer,
muxer: VideoMuxer,
trimRegions?: TrimRegion[],
): Promise<void> {
let audioConfig: AudioDecoderConfig;
try {
audioConfig = await demuxer.getDecoderConfig('audio') as AudioDecoderConfig;
} catch {
console.warn('[AudioProcessor] No audio track found, skipping');
return;
}
const codecCheck = await AudioDecoder.isConfigSupported(audioConfig);
if (!codecCheck.supported) {
console.warn('[AudioProcessor] Audio codec not supported:', audioConfig.codec);
return;
}
const sortedTrims = trimRegions
? [...trimRegions].sort((a, b) => a.startMs - b.startMs)
: [];
// Phase 1: Decode audio from source, skipping trimmed regions
const decodedFrames: AudioData[] = [];
const decoder = new AudioDecoder({
output: (data: AudioData) => decodedFrames.push(data),
error: (e: DOMException) => console.error('[AudioProcessor] Decode error:', e),
});
decoder.configure(audioConfig);
const reader = (demuxer.read('audio') as ReadableStream<EncodedAudioChunk>).getReader();
while (!this.cancelled) {
const { done, value: chunk } = await reader.read();
if (done || !chunk) break;
const timestampMs = chunk.timestamp / 1000;
if (this.isInTrimRegion(timestampMs, sortedTrims)) continue;
decoder.decode(chunk);
while (decoder.decodeQueueSize > DECODE_BACKPRESSURE_LIMIT && !this.cancelled) {
await new Promise(resolve => setTimeout(resolve, 1));
}
}
if (decoder.state === 'configured') {
await decoder.flush();
decoder.close();
}
if (this.cancelled || decodedFrames.length === 0) {
for (const f of decodedFrames) f.close();
return;
}
// Phase 2: Re-encode with timestamps adjusted for trim gaps
const encodedChunks: { chunk: EncodedAudioChunk; meta?: EncodedAudioChunkMetadata }[] = [];
const encoder = new AudioEncoder({
output: (chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata) => {
encodedChunks.push({ chunk, meta });
},
error: (e: DOMException) => console.error('[AudioProcessor] Encode error:', e),
});
const sampleRate = audioConfig.sampleRate || 48000;
const channels = audioConfig.numberOfChannels || 2;
const encodeConfig: AudioEncoderConfig = {
codec: 'opus',
sampleRate,
numberOfChannels: channels,
bitrate: AUDIO_BITRATE,
};
const encodeSupport = await AudioEncoder.isConfigSupported(encodeConfig);
if (!encodeSupport.supported) {
console.warn('[AudioProcessor] Opus encoding not supported, skipping audio');
for (const f of decodedFrames) f.close();
return;
}
encoder.configure(encodeConfig);
for (const audioData of decodedFrames) {
if (this.cancelled) {
audioData.close();
continue;
}
const timestampMs = audioData.timestamp / 1000;
const trimOffsetMs = this.computeTrimOffset(timestampMs, sortedTrims);
const adjustedTimestampUs = audioData.timestamp - trimOffsetMs * 1000;
const adjusted = this.cloneWithTimestamp(audioData, Math.max(0, adjustedTimestampUs));
audioData.close();
encoder.encode(adjusted);
adjusted.close();
}
if (encoder.state === 'configured') {
await encoder.flush();
encoder.close();
}
// Phase 3: Flush encoded chunks to muxer
for (const { chunk, meta } of encodedChunks) {
if (this.cancelled) break;
await muxer.addAudioChunk(chunk, meta);
}
console.log(`[AudioProcessor] Processed ${decodedFrames.length} audio frames, encoded ${encodedChunks.length} chunks`);
}
private cloneWithTimestamp(src: AudioData, newTimestamp: number): AudioData {
const isPlanar = src.format?.includes('planar') ?? false;
const numPlanes = isPlanar ? src.numberOfChannels : 1;
let totalSize = 0;
for (let p = 0; p < numPlanes; p++) {
totalSize += src.allocationSize({ planeIndex: p });
}
const buffer = new ArrayBuffer(totalSize);
let offset = 0;
for (let p = 0; p < numPlanes; p++) {
const planeSize = src.allocationSize({ planeIndex: p });
src.copyTo(new Uint8Array(buffer, offset, planeSize), { planeIndex: p });
offset += planeSize;
}
return new AudioData({
format: src.format!,
sampleRate: src.sampleRate,
numberOfFrames: src.numberOfFrames,
numberOfChannels: src.numberOfChannels,
timestamp: newTimestamp,
data: buffer,
});
}
private isInTrimRegion(timestampMs: number, trims: TrimRegion[]): boolean {
return trims.some(t => timestampMs >= t.startMs && timestampMs < t.endMs);
}
private computeTrimOffset(timestampMs: number, trims: TrimRegion[]): number {
let offset = 0;
for (const trim of trims) {
if (trim.endMs <= timestampMs) {
offset += trim.endMs - trim.startMs;
}
}
return offset;
}
cancel(): void {
this.cancelled = true;
}
}
import type { WebDemuxer } from "web-demuxer";
import type { TrimRegion } from "@/components/video-editor/types";
import type { VideoMuxer } from "./muxer";
const AUDIO_BITRATE = 128_000;
const DECODE_BACKPRESSURE_LIMIT = 20;
export class AudioProcessor {
private cancelled = false;
async process(demuxer: WebDemuxer, muxer: VideoMuxer, trimRegions?: TrimRegion[]): Promise<void> {
let audioConfig: AudioDecoderConfig;
try {
audioConfig = (await demuxer.getDecoderConfig("audio")) as AudioDecoderConfig;
} catch {
console.warn("[AudioProcessor] No audio track found, skipping");
return;
}
const codecCheck = await AudioDecoder.isConfigSupported(audioConfig);
if (!codecCheck.supported) {
console.warn("[AudioProcessor] Audio codec not supported:", audioConfig.codec);
return;
}
const sortedTrims = trimRegions ? [...trimRegions].sort((a, b) => a.startMs - b.startMs) : [];
// Phase 1: Decode audio from source, skipping trimmed regions
const decodedFrames: AudioData[] = [];
const decoder = new AudioDecoder({
output: (data: AudioData) => decodedFrames.push(data),
error: (e: DOMException) => console.error("[AudioProcessor] Decode error:", e),
});
decoder.configure(audioConfig);
const reader = (demuxer.read("audio") as ReadableStream<EncodedAudioChunk>).getReader();
while (!this.cancelled) {
const { done, value: chunk } = await reader.read();
if (done || !chunk) break;
const timestampMs = chunk.timestamp / 1000;
if (this.isInTrimRegion(timestampMs, sortedTrims)) continue;
decoder.decode(chunk);
while (decoder.decodeQueueSize > DECODE_BACKPRESSURE_LIMIT && !this.cancelled) {
await new Promise((resolve) => setTimeout(resolve, 1));
}
}
if (decoder.state === "configured") {
await decoder.flush();
decoder.close();
}
if (this.cancelled || decodedFrames.length === 0) {
for (const f of decodedFrames) f.close();
return;
}
// Phase 2: Re-encode with timestamps adjusted for trim gaps
const encodedChunks: { chunk: EncodedAudioChunk; meta?: EncodedAudioChunkMetadata }[] = [];
const encoder = new AudioEncoder({
output: (chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata) => {
encodedChunks.push({ chunk, meta });
},
error: (e: DOMException) => console.error("[AudioProcessor] Encode error:", e),
});
const sampleRate = audioConfig.sampleRate || 48000;
const channels = audioConfig.numberOfChannels || 2;
const encodeConfig: AudioEncoderConfig = {
codec: "opus",
sampleRate,
numberOfChannels: channels,
bitrate: AUDIO_BITRATE,
};
const encodeSupport = await AudioEncoder.isConfigSupported(encodeConfig);
if (!encodeSupport.supported) {
console.warn("[AudioProcessor] Opus encoding not supported, skipping audio");
for (const f of decodedFrames) f.close();
return;
}
encoder.configure(encodeConfig);
for (const audioData of decodedFrames) {
if (this.cancelled) {
audioData.close();
continue;
}
const timestampMs = audioData.timestamp / 1000;
const trimOffsetMs = this.computeTrimOffset(timestampMs, sortedTrims);
const adjustedTimestampUs = audioData.timestamp - trimOffsetMs * 1000;
const adjusted = this.cloneWithTimestamp(audioData, Math.max(0, adjustedTimestampUs));
audioData.close();
encoder.encode(adjusted);
adjusted.close();
}
if (encoder.state === "configured") {
await encoder.flush();
encoder.close();
}
// Phase 3: Flush encoded chunks to muxer
for (const { chunk, meta } of encodedChunks) {
if (this.cancelled) break;
await muxer.addAudioChunk(chunk, meta);
}
console.log(
`[AudioProcessor] Processed ${decodedFrames.length} audio frames, encoded ${encodedChunks.length} chunks`,
);
}
private cloneWithTimestamp(src: AudioData, newTimestamp: number): AudioData {
const isPlanar = src.format?.includes("planar") ?? false;
const numPlanes = isPlanar ? src.numberOfChannels : 1;
let totalSize = 0;
for (let p = 0; p < numPlanes; p++) {
totalSize += src.allocationSize({ planeIndex: p });
}
const buffer = new ArrayBuffer(totalSize);
let offset = 0;
for (let p = 0; p < numPlanes; p++) {
const planeSize = src.allocationSize({ planeIndex: p });
src.copyTo(new Uint8Array(buffer, offset, planeSize), { planeIndex: p });
offset += planeSize;
}
return new AudioData({
format: src.format!,
sampleRate: src.sampleRate,
numberOfFrames: src.numberOfFrames,
numberOfChannels: src.numberOfChannels,
timestamp: newTimestamp,
data: buffer,
});
}
private isInTrimRegion(timestampMs: number, trims: TrimRegion[]): boolean {
return trims.some((t) => timestampMs >= t.startMs && timestampMs < t.endMs);
}
private computeTrimOffset(timestampMs: number, trims: TrimRegion[]): number {
let offset = 0;
for (const trim of trims) {
if (trim.endMs <= timestampMs) {
offset += trim.endMs - trim.startMs;
}
}
return offset;
}
cancel(): void {
this.cancelled = true;
}
}
File diff suppressed because it is too large Load Diff
+229 -214
View File
@@ -1,34 +1,46 @@
import GIF from 'gif.js';
import type { ExportProgress, ExportResult, GifFrameRate, GifSizePreset, GIF_SIZE_PRESETS } from './types';
import { StreamingVideoDecoder } from './streamingDecoder';
import { FrameRenderer } from './frameRenderer';
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion } from '@/components/video-editor/types';
import GIF from "gif.js";
import type {
AnnotationRegion,
CropRegion,
SpeedRegion,
TrimRegion,
ZoomRegion,
} from "@/components/video-editor/types";
import { FrameRenderer } from "./frameRenderer";
import { StreamingVideoDecoder } from "./streamingDecoder";
import type {
ExportProgress,
ExportResult,
GIF_SIZE_PRESETS,
GifFrameRate,
GifSizePreset,
} from "./types";
const GIF_WORKER_URL = new URL('gif.js/dist/gif.worker.js', import.meta.url).toString();
const GIF_WORKER_URL = new URL("gif.js/dist/gif.worker.js", import.meta.url).toString();
interface GifExporterConfig {
videoUrl: string;
width: number;
height: number;
frameRate: GifFrameRate;
loop: boolean;
sizePreset: GifSizePreset;
wallpaper: string;
zoomRegions: ZoomRegion[];
trimRegions?: TrimRegion[];
speedRegions?: SpeedRegion[];
showShadow: boolean;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled?: boolean;
borderRadius?: number;
padding?: number;
videoPadding?: number;
cropRegion: CropRegion;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
onProgress?: (progress: ExportProgress) => void;
videoUrl: string;
width: number;
height: number;
frameRate: GifFrameRate;
loop: boolean;
sizePreset: GifSizePreset;
wallpaper: string;
zoomRegions: ZoomRegion[];
trimRegions?: TrimRegion[];
speedRegions?: SpeedRegion[];
showShadow: boolean;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled?: boolean;
borderRadius?: number;
padding?: number;
videoPadding?: number;
cropRegion: CropRegion;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
onProgress?: (progress: ExportProgress) => void;
}
/**
@@ -40,223 +52,226 @@ interface GifExporterConfig {
* @returns The calculated output dimensions
*/
export function calculateOutputDimensions(
sourceWidth: number,
sourceHeight: number,
sizePreset: GifSizePreset,
sizePresets: typeof GIF_SIZE_PRESETS
sourceWidth: number,
sourceHeight: number,
sizePreset: GifSizePreset,
sizePresets: typeof GIF_SIZE_PRESETS,
): { width: number; height: number } {
const preset = sizePresets[sizePreset];
const maxHeight = preset.maxHeight;
const preset = sizePresets[sizePreset];
const maxHeight = preset.maxHeight;
// If original is smaller than max height or preset is 'original', use source dimensions
if (sourceHeight <= maxHeight || sizePreset === 'original') {
return { width: sourceWidth, height: sourceHeight };
}
// If original is smaller than max height or preset is 'original', use source dimensions
if (sourceHeight <= maxHeight || sizePreset === "original") {
return { width: sourceWidth, height: sourceHeight };
}
// Calculate scaled dimensions preserving aspect ratio
const aspectRatio = sourceWidth / sourceHeight;
const newHeight = maxHeight;
const newWidth = Math.round(newHeight * aspectRatio);
// Calculate scaled dimensions preserving aspect ratio
const aspectRatio = sourceWidth / sourceHeight;
const newHeight = maxHeight;
const newWidth = Math.round(newHeight * aspectRatio);
// Ensure dimensions are even (required for some encoders)
return {
width: newWidth % 2 === 0 ? newWidth : newWidth + 1,
height: newHeight % 2 === 0 ? newHeight : newHeight + 1,
};
// Ensure dimensions are even (required for some encoders)
return {
width: newWidth % 2 === 0 ? newWidth : newWidth + 1,
height: newHeight % 2 === 0 ? newHeight : newHeight + 1,
};
}
export class GifExporter {
private config: GifExporterConfig;
private streamingDecoder: StreamingVideoDecoder | null = null;
private renderer: FrameRenderer | null = null;
private gif: GIF | null = null;
private cancelled = false;
private config: GifExporterConfig;
private streamingDecoder: StreamingVideoDecoder | null = null;
private renderer: FrameRenderer | null = null;
private gif: GIF | null = null;
private cancelled = false;
constructor(config: GifExporterConfig) {
this.config = config;
}
constructor(config: GifExporterConfig) {
this.config = config;
}
async export(): Promise<ExportResult> {
try {
this.cleanup();
this.cancelled = false;
async export(): Promise<ExportResult> {
try {
this.cleanup();
this.cancelled = false;
// Initialize streaming decoder and load video metadata
this.streamingDecoder = new StreamingVideoDecoder();
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
// Initialize streaming decoder and load video metadata
this.streamingDecoder = new StreamingVideoDecoder();
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
// Initialize frame renderer
this.renderer = new FrameRenderer({
width: this.config.width,
height: this.config.height,
wallpaper: this.config.wallpaper,
zoomRegions: this.config.zoomRegions,
showShadow: this.config.showShadow,
shadowIntensity: this.config.shadowIntensity,
showBlur: this.config.showBlur,
motionBlurEnabled: this.config.motionBlurEnabled,
borderRadius: this.config.borderRadius,
padding: this.config.padding,
cropRegion: this.config.cropRegion,
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
});
await this.renderer.initialize();
// Initialize frame renderer
this.renderer = new FrameRenderer({
width: this.config.width,
height: this.config.height,
wallpaper: this.config.wallpaper,
zoomRegions: this.config.zoomRegions,
showShadow: this.config.showShadow,
shadowIntensity: this.config.shadowIntensity,
showBlur: this.config.showBlur,
motionBlurEnabled: this.config.motionBlurEnabled,
borderRadius: this.config.borderRadius,
padding: this.config.padding,
cropRegion: this.config.cropRegion,
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
});
await this.renderer.initialize();
// Initialize GIF encoder
// Loop: 0 = infinite loop, 1 = play once (no loop)
const repeat = this.config.loop ? 0 : 1;
// Initialize GIF encoder
// Loop: 0 = infinite loop, 1 = play once (no loop)
const repeat = this.config.loop ? 0 : 1;
this.gif = new GIF({
workers: 4,
quality: 10,
width: this.config.width,
height: this.config.height,
workerScript: GIF_WORKER_URL,
repeat,
background: '#000000',
transparent: null,
dither: 'FloydSteinberg',
});
this.gif = new GIF({
workers: 4,
quality: 10,
width: this.config.width,
height: this.config.height,
workerScript: GIF_WORKER_URL,
repeat,
background: "#000000",
transparent: null,
dither: "FloydSteinberg",
});
// Calculate effective duration and frame count (excluding trim regions)
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions, this.config.speedRegions);
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
// Calculate effective duration and frame count (excluding trim regions)
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(
this.config.trimRegions,
this.config.speedRegions,
);
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
// Calculate frame delay in milliseconds (gif.js uses ms)
const frameDelay = Math.round(1000 / this.config.frameRate);
// Calculate frame delay in milliseconds (gif.js uses ms)
const frameDelay = Math.round(1000 / this.config.frameRate);
console.log('[GifExporter] Original duration:', videoInfo.duration, 's');
console.log('[GifExporter] Effective duration:', effectiveDuration, 's');
console.log('[GifExporter] Total frames to export:', totalFrames);
console.log('[GifExporter] Frame rate:', this.config.frameRate, 'FPS');
console.log('[GifExporter] Frame delay:', frameDelay, 'ms');
console.log('[GifExporter] Loop:', this.config.loop ? 'infinite' : 'once');
console.log('[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)');
console.log("[GifExporter] Original duration:", videoInfo.duration, "s");
console.log("[GifExporter] Effective duration:", effectiveDuration, "s");
console.log("[GifExporter] Total frames to export:", totalFrames);
console.log("[GifExporter] Frame rate:", this.config.frameRate, "FPS");
console.log("[GifExporter] Frame delay:", frameDelay, "ms");
console.log("[GifExporter] Loop:", this.config.loop ? "infinite" : "once");
console.log("[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)");
let frameIndex = 0;
let frameIndex = 0;
// Stream decode and process frames — no seeking!
await this.streamingDecoder.decodeAll(
this.config.frameRate,
this.config.trimRegions,
this.config.speedRegions,
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
if (this.cancelled) {
videoFrame.close();
return;
}
// Stream decode and process frames — no seeking!
await this.streamingDecoder.decodeAll(
this.config.frameRate,
this.config.trimRegions,
this.config.speedRegions,
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
if (this.cancelled) {
videoFrame.close();
return;
}
// Render the frame with all effects using source timestamp
const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
await this.renderer!.renderFrame(videoFrame, sourceTimestampUs);
videoFrame.close();
// Render the frame with all effects using source timestamp
const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
await this.renderer!.renderFrame(videoFrame, sourceTimestampUs);
videoFrame.close();
// Get the rendered canvas and add to GIF
const canvas = this.renderer!.getCanvas();
// Get the rendered canvas and add to GIF
const canvas = this.renderer!.getCanvas();
// Add frame to GIF encoder with delay
this.gif!.addFrame(canvas, { delay: frameDelay, copy: true });
// Add frame to GIF encoder with delay
this.gif!.addFrame(canvas, { delay: frameDelay, copy: true });
frameIndex++;
frameIndex++;
// Update progress
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: frameIndex,
totalFrames,
percentage: (frameIndex / totalFrames) * 100,
estimatedTimeRemaining: 0,
});
}
}
);
// Update progress
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: frameIndex,
totalFrames,
percentage: (frameIndex / totalFrames) * 100,
estimatedTimeRemaining: 0,
});
}
},
);
if (this.cancelled) {
return { success: false, error: 'Export cancelled' };
}
if (this.cancelled) {
return { success: false, error: "Export cancelled" };
}
// Update progress to show we're now in the finalizing phase
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: totalFrames,
totalFrames,
percentage: 100,
estimatedTimeRemaining: 0,
phase: 'finalizing',
});
}
// Update progress to show we're now in the finalizing phase
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: totalFrames,
totalFrames,
percentage: 100,
estimatedTimeRemaining: 0,
phase: "finalizing",
});
}
// Render the GIF
const blob = await new Promise<Blob>((resolve, _reject) => {
this.gif!.on('finished', (blob: Blob) => {
resolve(blob);
});
// Render the GIF
const blob = await new Promise<Blob>((resolve, _reject) => {
this.gif!.on("finished", (blob: Blob) => {
resolve(blob);
});
// Track rendering progress
this.gif!.on('progress', (progress: number) => {
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: totalFrames,
totalFrames,
percentage: 100,
estimatedTimeRemaining: 0,
phase: 'finalizing',
renderProgress: Math.round(progress * 100),
});
}
});
// Track rendering progress
this.gif!.on("progress", (progress: number) => {
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: totalFrames,
totalFrames,
percentage: 100,
estimatedTimeRemaining: 0,
phase: "finalizing",
renderProgress: Math.round(progress * 100),
});
}
});
// gif.js doesn't have a typed 'error' event, but we can catch errors in the try/catch
this.gif!.render();
});
// gif.js doesn't have a typed 'error' event, but we can catch errors in the try/catch
this.gif!.render();
});
return { success: true, blob };
} catch (error) {
console.error('GIF Export error:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
} finally {
this.cleanup();
}
}
return { success: true, blob };
} catch (error) {
console.error("GIF Export error:", error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
} finally {
this.cleanup();
}
}
cancel(): void {
this.cancelled = true;
if (this.streamingDecoder) {
this.streamingDecoder.cancel();
}
if (this.gif) {
this.gif.abort();
}
this.cleanup();
}
cancel(): void {
this.cancelled = true;
if (this.streamingDecoder) {
this.streamingDecoder.cancel();
}
if (this.gif) {
this.gif.abort();
}
this.cleanup();
}
private cleanup(): void {
if (this.streamingDecoder) {
try {
this.streamingDecoder.destroy();
} catch (e) {
console.warn('Error destroying streaming decoder:', e);
}
this.streamingDecoder = null;
}
private cleanup(): void {
if (this.streamingDecoder) {
try {
this.streamingDecoder.destroy();
} catch (e) {
console.warn("Error destroying streaming decoder:", e);
}
this.streamingDecoder = null;
}
if (this.renderer) {
try {
this.renderer.destroy();
} catch (e) {
console.warn('Error destroying renderer:', e);
}
this.renderer = null;
}
if (this.renderer) {
try {
this.renderer.destroy();
} catch (e) {
console.warn("Error destroying renderer:", e);
}
this.renderer = null;
}
this.gif = null;
}
this.gif = null;
}
}
+24 -25
View File
@@ -1,25 +1,24 @@
export { VideoExporter } from './videoExporter';
export { VideoFileDecoder } from './videoDecoder';
export { StreamingVideoDecoder } from './streamingDecoder';
export { FrameRenderer } from './frameRenderer';
export { VideoMuxer } from './muxer';
export { GifExporter, calculateOutputDimensions } from './gifExporter';
export type {
ExportConfig,
ExportProgress,
ExportResult,
VideoFrameData,
ExportQuality,
ExportFormat,
GifFrameRate,
GifSizePreset,
GifExportConfig,
ExportSettings,
} from './types';
export {
GIF_SIZE_PRESETS,
GIF_FRAME_RATES,
VALID_GIF_FRAME_RATES,
isValidGifFrameRate
} from './types';
export { FrameRenderer } from "./frameRenderer";
export { calculateOutputDimensions, GifExporter } from "./gifExporter";
export { VideoMuxer } from "./muxer";
export { StreamingVideoDecoder } from "./streamingDecoder";
export type {
ExportConfig,
ExportFormat,
ExportProgress,
ExportQuality,
ExportResult,
ExportSettings,
GifExportConfig,
GifFrameRate,
GifSizePreset,
VideoFrameData,
} from "./types";
export {
GIF_FRAME_RATES,
GIF_SIZE_PRESETS,
isValidGifFrameRate,
VALID_GIF_FRAME_RATES,
} from "./types";
export { VideoFileDecoder } from "./videoDecoder";
export { VideoExporter } from "./videoExporter";
+78 -78
View File
@@ -1,89 +1,89 @@
import type { ExportConfig } from './types';
import {
Output,
Mp4OutputFormat,
BufferTarget,
EncodedVideoPacketSource,
EncodedAudioPacketSource,
EncodedPacket
} from 'mediabunny';
import {
BufferTarget,
EncodedAudioPacketSource,
EncodedPacket,
EncodedVideoPacketSource,
Mp4OutputFormat,
Output,
} from "mediabunny";
import type { ExportConfig } from "./types";
export class VideoMuxer {
private output: Output | null = null;
private videoSource: EncodedVideoPacketSource | null = null;
private audioSource: EncodedAudioPacketSource | null = null;
private hasAudio: boolean;
private target: BufferTarget | null = null;
private config: ExportConfig;
private output: Output | null = null;
private videoSource: EncodedVideoPacketSource | null = null;
private audioSource: EncodedAudioPacketSource | null = null;
private hasAudio: boolean;
private target: BufferTarget | null = null;
private config: ExportConfig;
constructor(config: ExportConfig, hasAudio = false) {
this.config = config;
this.hasAudio = hasAudio;
}
constructor(config: ExportConfig, hasAudio = false) {
this.config = config;
this.hasAudio = hasAudio;
}
async initialize(): Promise<void> {
// Create the buffer target
this.target = new BufferTarget();
this.output = new Output({
format: new Mp4OutputFormat({
fastStart: 'in-memory',
}),
target: this.target,
});
async initialize(): Promise<void> {
// Create the buffer target
this.target = new BufferTarget();
// Create video source - codec will be deduced from metadata
this.videoSource = new EncodedVideoPacketSource('avc');
this.output.addVideoTrack(this.videoSource, {
frameRate: this.config.frameRate,
});
this.output = new Output({
format: new Mp4OutputFormat({
fastStart: "in-memory",
}),
target: this.target,
});
// Create audio source if needed
if (this.hasAudio) {
this.audioSource = new EncodedAudioPacketSource('opus');
this.output.addAudioTrack(this.audioSource);
}
// Create video source - codec will be deduced from metadata
this.videoSource = new EncodedVideoPacketSource("avc");
this.output.addVideoTrack(this.videoSource, {
frameRate: this.config.frameRate,
});
// Start the output to begin accepting media data
await this.output.start();
}
// Create audio source if needed
if (this.hasAudio) {
this.audioSource = new EncodedAudioPacketSource("opus");
this.output.addAudioTrack(this.audioSource);
}
async addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): Promise<void> {
if (!this.videoSource) {
throw new Error('Muxer not initialized');
}
// Convert WebCodecs chunk to Mediabunny packet
const packet = EncodedPacket.fromEncodedChunk(chunk);
// Add metadata with the first chunk
await this.videoSource.add(packet, meta);
}
// Start the output to begin accepting media data
await this.output.start();
}
async addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): Promise<void> {
if (!this.audioSource) {
throw new Error('Audio not configured for this muxer');
}
// Convert WebCodecs chunk to Mediabunny packet
const packet = EncodedPacket.fromEncodedChunk(chunk);
// Add metadata with the first chunk
await this.audioSource.add(packet, meta);
}
async addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): Promise<void> {
if (!this.videoSource) {
throw new Error("Muxer not initialized");
}
async finalize(): Promise<Blob> {
if (!this.output || !this.target) {
throw new Error('Muxer not initialized');
}
await this.output.finalize();
const buffer = this.target.buffer;
if (!buffer) {
throw new Error('Failed to finalize output');
}
return new Blob([buffer], { type: 'video/mp4' });
}
// Convert WebCodecs chunk to Mediabunny packet
const packet = EncodedPacket.fromEncodedChunk(chunk);
// Add metadata with the first chunk
await this.videoSource.add(packet, meta);
}
async addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): Promise<void> {
if (!this.audioSource) {
throw new Error("Audio not configured for this muxer");
}
// Convert WebCodecs chunk to Mediabunny packet
const packet = EncodedPacket.fromEncodedChunk(chunk);
// Add metadata with the first chunk
await this.audioSource.add(packet, meta);
}
async finalize(): Promise<Blob> {
if (!this.output || !this.target) {
throw new Error("Muxer not initialized");
}
await this.output.finalize();
const buffer = this.target.buffer;
if (!buffer) {
throw new Error("Failed to finalize output");
}
return new Blob([buffer], { type: "video/mp4" });
}
}
+371 -344
View File
@@ -1,344 +1,371 @@
import { WebDemuxer } from 'web-demuxer';
import type { TrimRegion, SpeedRegion } from '@/components/video-editor/types';
export interface DecodedVideoInfo {
width: number;
height: number;
duration: number; // seconds
frameRate: number;
codec: string;
hasAudio: boolean;
audioCodec?: string;
}
/** Caller must close the VideoFrame after use. */
type OnFrameCallback = (
frame: VideoFrame,
exportTimestampUs: number,
sourceTimestampMs: number
) => Promise<void>;
/**
* Decodes video frames via web-demuxer + VideoDecoder in a single forward pass.
* Way faster than seeking an HTMLVideoElement per frame.
*
* Frames in trimmed regions are decoded (needed for P/B-frame state) but discarded.
* Non-trimmed frames get buffered per segment and resampled to the target frame rate.
*/
export class StreamingVideoDecoder {
private demuxer: WebDemuxer | null = null;
private decoder: VideoDecoder | null = null;
private cancelled = false;
private metadata: DecodedVideoInfo | null = null;
async loadMetadata(videoUrl: string): Promise<DecodedVideoInfo> {
const response = await fetch(videoUrl);
const blob = await response.blob();
const filename = videoUrl.split('/').pop() || 'video';
const file = new File([blob], filename, { type: blob.type });
// Relative URL so it resolves correctly in both dev (http) and packaged (file://) builds
const wasmUrl = new URL('./wasm/web-demuxer.wasm', window.location.href).href;
this.demuxer = new WebDemuxer({ wasmFilePath: wasmUrl });
await this.demuxer.load(file);
const mediaInfo = await this.demuxer.getMediaInfo();
const videoStream = mediaInfo.streams.find(s => s.codec_type_string === 'video');
let frameRate = 60;
if (videoStream?.avg_frame_rate) {
const parts = videoStream.avg_frame_rate.split('/');
if (parts.length === 2) {
const num = parseInt(parts[0], 10);
const den = parseInt(parts[1], 10);
if (den > 0 && num > 0) frameRate = num / den;
}
}
const audioStream = mediaInfo.streams.find(s => s.codec_type_string === 'audio');
this.metadata = {
width: videoStream?.width || 1920,
height: videoStream?.height || 1080,
duration: mediaInfo.duration,
frameRate,
codec: videoStream?.codec_string || 'unknown',
hasAudio: !!audioStream,
audioCodec: audioStream?.codec_string,
};
return this.metadata;
}
async decodeAll(
targetFrameRate: number,
trimRegions: TrimRegion[] | undefined,
speedRegions: SpeedRegion[] | undefined,
onFrame: OnFrameCallback
): Promise<void> {
if (!this.demuxer || !this.metadata) {
throw new Error('Must call loadMetadata() before decodeAll()');
}
const decoderConfig = await this.demuxer.getDecoderConfig('video');
const segments = this.splitBySpeed(
this.computeSegments(this.metadata.duration, trimRegions),
speedRegions
);
const frameDurationUs = 1_000_000 / targetFrameRate;
// Async frame queue — decoder pushes, consumer pulls
const pendingFrames: VideoFrame[] = [];
let frameResolve: ((frame: VideoFrame | null) => void) | null = null;
let decodeError: Error | null = null;
let decodeDone = false;
this.decoder = new VideoDecoder({
output: (frame: VideoFrame) => {
if (frameResolve) {
const resolve = frameResolve;
frameResolve = null;
resolve(frame);
} else {
pendingFrames.push(frame);
}
},
error: (e: DOMException) => {
decodeError = new Error(`VideoDecoder error: ${e.message}`);
if (frameResolve) {
const resolve = frameResolve;
frameResolve = null;
resolve(null);
}
},
});
this.decoder.configure(decoderConfig);
const getNextFrame = (): Promise<VideoFrame | null> => {
if (decodeError) throw decodeError;
if (pendingFrames.length > 0) return Promise.resolve(pendingFrames.shift()!);
if (decodeDone) return Promise.resolve(null);
return new Promise(resolve => { frameResolve = resolve; });
};
// One forward stream through the whole file
const reader = this.demuxer.read('video').getReader();
// Feed chunks to decoder in background with backpressure
const feedPromise = (async () => {
try {
while (!this.cancelled) {
const { done, value: chunk } = await reader.read();
if (done || !chunk) break;
while (this.decoder!.decodeQueueSize > 10 && !this.cancelled) {
await new Promise(resolve => setTimeout(resolve, 1));
}
if (this.cancelled) break;
this.decoder!.decode(chunk);
}
if (!this.cancelled && this.decoder!.state === 'configured') {
await this.decoder!.flush();
}
} catch (e) {
decodeError = e instanceof Error ? e : new Error(String(e));
} finally {
decodeDone = true;
if (frameResolve) {
const resolve = frameResolve;
frameResolve = null;
resolve(null);
}
}
})();
// Route decoded frames into segments by timestamp, then deliver with VFR→CFR resampling
let segmentIdx = 0;
let exportFrameIndex = 0;
let segmentBuffer: VideoFrame[] = [];
while (!this.cancelled && segmentIdx < segments.length) {
const frame = await getNextFrame();
if (!frame) break;
const frameTimeSec = frame.timestamp / 1_000_000;
const currentSegment = segments[segmentIdx];
// Before current segment — trimmed or pre-video
if (frameTimeSec < currentSegment.startSec - 0.001) {
frame.close();
continue;
}
// Past current segment — flush buffer and advance
if (frameTimeSec >= currentSegment.endSec - 0.001) {
exportFrameIndex = await this.deliverSegment(
segmentBuffer, currentSegment, targetFrameRate, frameDurationUs, exportFrameIndex, onFrame
);
for (const f of segmentBuffer) f.close();
segmentBuffer = [];
segmentIdx++;
while (segmentIdx < segments.length && frameTimeSec >= segments[segmentIdx].endSec - 0.001) {
segmentIdx++;
}
if (segmentIdx < segments.length && frameTimeSec >= segments[segmentIdx].startSec - 0.001) {
segmentBuffer.push(frame);
} else {
frame.close();
}
continue;
}
segmentBuffer.push(frame);
}
// Flush last segment
if (segmentBuffer.length > 0 && segmentIdx < segments.length) {
exportFrameIndex = await this.deliverSegment(
segmentBuffer, segments[segmentIdx], targetFrameRate, frameDurationUs, exportFrameIndex, onFrame
);
for (const f of segmentBuffer) f.close();
}
// Drain leftover decoded frames
while (!decodeDone) {
const frame = await getNextFrame();
if (!frame) break;
frame.close();
}
try { reader.cancel(); } catch { /* already closed */ }
await feedPromise;
for (const f of pendingFrames) f.close();
pendingFrames.length = 0;
if (this.decoder?.state === 'configured') {
this.decoder.close();
}
this.decoder = null;
}
/**
* Resample buffered frames to fill the target frame count for this segment.
* Handles VFR sources by duplicating/decimating as needed.
*/
private async deliverSegment(
frames: VideoFrame[],
segment: { startSec: number; endSec: number; speed: number },
targetFrameRate: number,
frameDurationUs: number,
startExportFrameIndex: number,
onFrame: OnFrameCallback
): Promise<number> {
if (frames.length === 0) return startExportFrameIndex;
const segmentFrameCount = Math.ceil(
(segment.endSec - segment.startSec) / segment.speed * targetFrameRate
);
let exportFrameIndex = startExportFrameIndex;
for (let i = 0; i < segmentFrameCount && !this.cancelled; i++) {
const sourceIdx = Math.min(
Math.floor(i * frames.length / segmentFrameCount),
frames.length - 1
);
const sourceFrame = frames[sourceIdx];
const clone = new VideoFrame(sourceFrame, { timestamp: sourceFrame.timestamp });
await onFrame(clone, exportFrameIndex * frameDurationUs, sourceFrame.timestamp / 1000);
exportFrameIndex++;
}
return exportFrameIndex;
}
private computeSegments(
totalDuration: number,
trimRegions?: TrimRegion[]
): Array<{ startSec: number; endSec: number }> {
if (!trimRegions || trimRegions.length === 0) {
return [{ startSec: 0, endSec: totalDuration }];
}
const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs);
const segments: Array<{ startSec: number; endSec: number }> = [];
let cursor = 0;
for (const trim of sorted) {
const trimStart = trim.startMs / 1000;
const trimEnd = trim.endMs / 1000;
if (cursor < trimStart) {
segments.push({ startSec: cursor, endSec: trimStart });
}
cursor = trimEnd;
}
if (cursor < totalDuration) {
segments.push({ startSec: cursor, endSec: totalDuration });
}
return segments;
}
getEffectiveDuration(trimRegions?: TrimRegion[], speedRegions?: SpeedRegion[]): number {
if (!this.metadata) throw new Error('Must call loadMetadata() first');
const trimSegments = this.computeSegments(this.metadata.duration, trimRegions);
const speedSegments = this.splitBySpeed(trimSegments, speedRegions);
return speedSegments.reduce((sum, seg) => sum + (seg.endSec - seg.startSec) / seg.speed, 0);
}
private splitBySpeed(
segments: Array<{ startSec: number; endSec: number }>,
speedRegions?: SpeedRegion[]
): Array<{ startSec: number; endSec: number; speed: number }> {
if (!speedRegions || speedRegions.length === 0)
return segments.map(s => ({ ...s, speed: 1 }));
const result: Array<{ startSec: number; endSec: number; speed: number }> = [];
for (const segment of segments) {
const overlapping = speedRegions
.filter(sr => (sr.startMs / 1000) < segment.endSec && (sr.endMs / 1000) > segment.startSec)
.sort((a, b) => a.startMs - b.startMs);
if (overlapping.length === 0) { result.push({ ...segment, speed: 1 }); continue; }
let cursor = segment.startSec;
for (const sr of overlapping) {
const srStart = Math.max(sr.startMs / 1000, segment.startSec);
const srEnd = Math.min(sr.endMs / 1000, segment.endSec);
if (cursor < srStart) result.push({ startSec: cursor, endSec: srStart, speed: 1 });
result.push({ startSec: srStart, endSec: srEnd, speed: sr.speed });
cursor = srEnd;
}
if (cursor < segment.endSec) result.push({ startSec: cursor, endSec: segment.endSec, speed: 1 });
}
return result.filter(s => s.endSec - s.startSec > 0.0001);
}
getDemuxer(): WebDemuxer | null {
return this.demuxer;
}
cancel(): void {
this.cancelled = true;
}
destroy(): void {
this.cancelled = true;
if (this.decoder) {
try {
if (this.decoder.state === 'configured') this.decoder.close();
} catch { /* ignore */ }
this.decoder = null;
}
if (this.demuxer) {
try { this.demuxer.destroy(); } catch { }
this.demuxer = null;
}
}
}
import { WebDemuxer } from "web-demuxer";
import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
export interface DecodedVideoInfo {
width: number;
height: number;
duration: number; // seconds
frameRate: number;
codec: string;
hasAudio: boolean;
audioCodec?: string;
}
/** Caller must close the VideoFrame after use. */
type OnFrameCallback = (
frame: VideoFrame,
exportTimestampUs: number,
sourceTimestampMs: number,
) => Promise<void>;
/**
* Decodes video frames via web-demuxer + VideoDecoder in a single forward pass.
* Way faster than seeking an HTMLVideoElement per frame.
*
* Frames in trimmed regions are decoded (needed for P/B-frame state) but discarded.
* Non-trimmed frames get buffered per segment and resampled to the target frame rate.
*/
export class StreamingVideoDecoder {
private demuxer: WebDemuxer | null = null;
private decoder: VideoDecoder | null = null;
private cancelled = false;
private metadata: DecodedVideoInfo | null = null;
async loadMetadata(videoUrl: string): Promise<DecodedVideoInfo> {
const response = await fetch(videoUrl);
const blob = await response.blob();
const filename = videoUrl.split("/").pop() || "video";
const file = new File([blob], filename, { type: blob.type });
// Relative URL so it resolves correctly in both dev (http) and packaged (file://) builds
const wasmUrl = new URL("./wasm/web-demuxer.wasm", window.location.href).href;
this.demuxer = new WebDemuxer({ wasmFilePath: wasmUrl });
await this.demuxer.load(file);
const mediaInfo = await this.demuxer.getMediaInfo();
const videoStream = mediaInfo.streams.find((s) => s.codec_type_string === "video");
let frameRate = 60;
if (videoStream?.avg_frame_rate) {
const parts = videoStream.avg_frame_rate.split("/");
if (parts.length === 2) {
const num = parseInt(parts[0], 10);
const den = parseInt(parts[1], 10);
if (den > 0 && num > 0) frameRate = num / den;
}
}
const audioStream = mediaInfo.streams.find((s) => s.codec_type_string === "audio");
this.metadata = {
width: videoStream?.width || 1920,
height: videoStream?.height || 1080,
duration: mediaInfo.duration,
frameRate,
codec: videoStream?.codec_string || "unknown",
hasAudio: !!audioStream,
audioCodec: audioStream?.codec_string,
};
return this.metadata;
}
async decodeAll(
targetFrameRate: number,
trimRegions: TrimRegion[] | undefined,
speedRegions: SpeedRegion[] | undefined,
onFrame: OnFrameCallback,
): Promise<void> {
if (!this.demuxer || !this.metadata) {
throw new Error("Must call loadMetadata() before decodeAll()");
}
const decoderConfig = await this.demuxer.getDecoderConfig("video");
const segments = this.splitBySpeed(
this.computeSegments(this.metadata.duration, trimRegions),
speedRegions,
);
const frameDurationUs = 1_000_000 / targetFrameRate;
// Async frame queue — decoder pushes, consumer pulls
const pendingFrames: VideoFrame[] = [];
let frameResolve: ((frame: VideoFrame | null) => void) | null = null;
let decodeError: Error | null = null;
let decodeDone = false;
this.decoder = new VideoDecoder({
output: (frame: VideoFrame) => {
if (frameResolve) {
const resolve = frameResolve;
frameResolve = null;
resolve(frame);
} else {
pendingFrames.push(frame);
}
},
error: (e: DOMException) => {
decodeError = new Error(`VideoDecoder error: ${e.message}`);
if (frameResolve) {
const resolve = frameResolve;
frameResolve = null;
resolve(null);
}
},
});
this.decoder.configure(decoderConfig);
const getNextFrame = (): Promise<VideoFrame | null> => {
if (decodeError) throw decodeError;
if (pendingFrames.length > 0) return Promise.resolve(pendingFrames.shift()!);
if (decodeDone) return Promise.resolve(null);
return new Promise((resolve) => {
frameResolve = resolve;
});
};
// One forward stream through the whole file
const reader = this.demuxer.read("video").getReader();
// Feed chunks to decoder in background with backpressure
const feedPromise = (async () => {
try {
while (!this.cancelled) {
const { done, value: chunk } = await reader.read();
if (done || !chunk) break;
while (this.decoder!.decodeQueueSize > 10 && !this.cancelled) {
await new Promise((resolve) => setTimeout(resolve, 1));
}
if (this.cancelled) break;
this.decoder!.decode(chunk);
}
if (!this.cancelled && this.decoder!.state === "configured") {
await this.decoder!.flush();
}
} catch (e) {
decodeError = e instanceof Error ? e : new Error(String(e));
} finally {
decodeDone = true;
if (frameResolve) {
const resolve = frameResolve;
frameResolve = null;
resolve(null);
}
}
})();
// Route decoded frames into segments by timestamp, then deliver with VFR→CFR resampling
let segmentIdx = 0;
let exportFrameIndex = 0;
let segmentBuffer: VideoFrame[] = [];
while (!this.cancelled && segmentIdx < segments.length) {
const frame = await getNextFrame();
if (!frame) break;
const frameTimeSec = frame.timestamp / 1_000_000;
const currentSegment = segments[segmentIdx];
// Before current segment — trimmed or pre-video
if (frameTimeSec < currentSegment.startSec - 0.001) {
frame.close();
continue;
}
// Past current segment — flush buffer and advance
if (frameTimeSec >= currentSegment.endSec - 0.001) {
exportFrameIndex = await this.deliverSegment(
segmentBuffer,
currentSegment,
targetFrameRate,
frameDurationUs,
exportFrameIndex,
onFrame,
);
for (const f of segmentBuffer) f.close();
segmentBuffer = [];
segmentIdx++;
while (
segmentIdx < segments.length &&
frameTimeSec >= segments[segmentIdx].endSec - 0.001
) {
segmentIdx++;
}
if (segmentIdx < segments.length && frameTimeSec >= segments[segmentIdx].startSec - 0.001) {
segmentBuffer.push(frame);
} else {
frame.close();
}
continue;
}
segmentBuffer.push(frame);
}
// Flush last segment
if (segmentBuffer.length > 0 && segmentIdx < segments.length) {
exportFrameIndex = await this.deliverSegment(
segmentBuffer,
segments[segmentIdx],
targetFrameRate,
frameDurationUs,
exportFrameIndex,
onFrame,
);
for (const f of segmentBuffer) f.close();
}
// Drain leftover decoded frames
while (!decodeDone) {
const frame = await getNextFrame();
if (!frame) break;
frame.close();
}
try {
reader.cancel();
} catch {
/* already closed */
}
await feedPromise;
for (const f of pendingFrames) f.close();
pendingFrames.length = 0;
if (this.decoder?.state === "configured") {
this.decoder.close();
}
this.decoder = null;
}
/**
* Resample buffered frames to fill the target frame count for this segment.
* Handles VFR sources by duplicating/decimating as needed.
*/
private async deliverSegment(
frames: VideoFrame[],
segment: { startSec: number; endSec: number; speed: number },
targetFrameRate: number,
frameDurationUs: number,
startExportFrameIndex: number,
onFrame: OnFrameCallback,
): Promise<number> {
if (frames.length === 0) return startExportFrameIndex;
const segmentFrameCount = Math.ceil(
((segment.endSec - segment.startSec) / segment.speed) * targetFrameRate,
);
let exportFrameIndex = startExportFrameIndex;
for (let i = 0; i < segmentFrameCount && !this.cancelled; i++) {
const sourceIdx = Math.min(
Math.floor((i * frames.length) / segmentFrameCount),
frames.length - 1,
);
const sourceFrame = frames[sourceIdx];
const clone = new VideoFrame(sourceFrame, { timestamp: sourceFrame.timestamp });
await onFrame(clone, exportFrameIndex * frameDurationUs, sourceFrame.timestamp / 1000);
exportFrameIndex++;
}
return exportFrameIndex;
}
private computeSegments(
totalDuration: number,
trimRegions?: TrimRegion[],
): Array<{ startSec: number; endSec: number }> {
if (!trimRegions || trimRegions.length === 0) {
return [{ startSec: 0, endSec: totalDuration }];
}
const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs);
const segments: Array<{ startSec: number; endSec: number }> = [];
let cursor = 0;
for (const trim of sorted) {
const trimStart = trim.startMs / 1000;
const trimEnd = trim.endMs / 1000;
if (cursor < trimStart) {
segments.push({ startSec: cursor, endSec: trimStart });
}
cursor = trimEnd;
}
if (cursor < totalDuration) {
segments.push({ startSec: cursor, endSec: totalDuration });
}
return segments;
}
getEffectiveDuration(trimRegions?: TrimRegion[], speedRegions?: SpeedRegion[]): number {
if (!this.metadata) throw new Error("Must call loadMetadata() first");
const trimSegments = this.computeSegments(this.metadata.duration, trimRegions);
const speedSegments = this.splitBySpeed(trimSegments, speedRegions);
return speedSegments.reduce((sum, seg) => sum + (seg.endSec - seg.startSec) / seg.speed, 0);
}
private splitBySpeed(
segments: Array<{ startSec: number; endSec: number }>,
speedRegions?: SpeedRegion[],
): Array<{ startSec: number; endSec: number; speed: number }> {
if (!speedRegions || speedRegions.length === 0)
return segments.map((s) => ({ ...s, speed: 1 }));
const result: Array<{ startSec: number; endSec: number; speed: number }> = [];
for (const segment of segments) {
const overlapping = speedRegions
.filter((sr) => sr.startMs / 1000 < segment.endSec && sr.endMs / 1000 > segment.startSec)
.sort((a, b) => a.startMs - b.startMs);
if (overlapping.length === 0) {
result.push({ ...segment, speed: 1 });
continue;
}
let cursor = segment.startSec;
for (const sr of overlapping) {
const srStart = Math.max(sr.startMs / 1000, segment.startSec);
const srEnd = Math.min(sr.endMs / 1000, segment.endSec);
if (cursor < srStart) result.push({ startSec: cursor, endSec: srStart, speed: 1 });
result.push({ startSec: srStart, endSec: srEnd, speed: sr.speed });
cursor = srEnd;
}
if (cursor < segment.endSec)
result.push({ startSec: cursor, endSec: segment.endSec, speed: 1 });
}
return result.filter((s) => s.endSec - s.startSec > 0.0001);
}
getDemuxer(): WebDemuxer | null {
return this.demuxer;
}
cancel(): void {
this.cancelled = true;
}
destroy(): void {
this.cancelled = true;
if (this.decoder) {
try {
if (this.decoder.state === "configured") this.decoder.close();
} catch {
/* ignore */
}
this.decoder = null;
}
if (this.demuxer) {
try {
this.demuxer.destroy();
} catch {}
this.demuxer = null;
}
}
}
+38 -38
View File
@@ -1,73 +1,73 @@
export interface ExportConfig {
width: number;
height: number;
frameRate: number;
bitrate: number;
codec?: string;
width: number;
height: number;
frameRate: number;
bitrate: number;
codec?: string;
}
export interface ExportProgress {
currentFrame: number;
totalFrames: number;
percentage: number;
estimatedTimeRemaining: number; // in seconds
phase?: 'extracting' | 'finalizing'; // Phase of export
renderProgress?: number; // 0-100, progress of GIF rendering phase
currentFrame: number;
totalFrames: number;
percentage: number;
estimatedTimeRemaining: number; // in seconds
phase?: "extracting" | "finalizing"; // Phase of export
renderProgress?: number; // 0-100, progress of GIF rendering phase
}
export interface ExportResult {
success: boolean;
blob?: Blob;
error?: string;
success: boolean;
blob?: Blob;
error?: string;
}
export interface VideoFrameData {
frame: VideoFrame;
timestamp: number; // in microseconds
duration: number; // in microseconds
frame: VideoFrame;
timestamp: number; // in microseconds
duration: number; // in microseconds
}
export type ExportQuality = 'medium' | 'good' | 'source';
export type ExportQuality = "medium" | "good" | "source";
// GIF Export Types
export type ExportFormat = 'mp4' | 'gif';
export type ExportFormat = "mp4" | "gif";
export type GifFrameRate = 15 | 20 | 25 | 30;
export type GifSizePreset = 'medium' | 'large' | 'original';
export type GifSizePreset = "medium" | "large" | "original";
export interface GifExportConfig {
frameRate: GifFrameRate;
loop: boolean;
sizePreset: GifSizePreset;
width: number;
height: number;
frameRate: GifFrameRate;
loop: boolean;
sizePreset: GifSizePreset;
width: number;
height: number;
}
export interface ExportSettings {
format: ExportFormat;
// MP4 settings
quality?: ExportQuality;
// GIF settings
gifConfig?: GifExportConfig;
format: ExportFormat;
// MP4 settings
quality?: ExportQuality;
// GIF settings
gifConfig?: GifExportConfig;
}
export const GIF_SIZE_PRESETS: Record<GifSizePreset, { maxHeight: number; label: string }> = {
medium: { maxHeight: 720, label: 'Medium (720p)' },
large: { maxHeight: 1080, label: 'Large (1080p)' },
original: { maxHeight: Infinity, label: 'Original' },
medium: { maxHeight: 720, label: "Medium (720p)" },
large: { maxHeight: 1080, label: "Large (1080p)" },
original: { maxHeight: Infinity, label: "Original" },
};
export const GIF_FRAME_RATES: { value: GifFrameRate; label: string }[] = [
{ value: 15, label: '15 FPS - Balanced' },
{ value: 20, label: '20 FPS - Smooth' },
{ value: 25, label: '25 FPS - Very smooth' },
{ value: 30, label: '30 FPS - Maximum' },
{ value: 15, label: "15 FPS - Balanced" },
{ value: 20, label: "20 FPS - Smooth" },
{ value: 25, label: "25 FPS - Very smooth" },
{ value: 30, label: "30 FPS - Maximum" },
];
// Valid frame rates for validation
export const VALID_GIF_FRAME_RATES: readonly GifFrameRate[] = [15, 20, 25, 30] as const;
export function isValidGifFrameRate(rate: number): rate is GifFrameRate {
return VALID_GIF_FRAME_RATES.includes(rate as GifFrameRate);
return VALID_GIF_FRAME_RATES.includes(rate as GifFrameRate);
}
+45 -45
View File
@@ -1,57 +1,57 @@
export interface DecodedVideoInfo {
width: number;
height: number;
duration: number; // in seconds
frameRate: number;
codec: string;
width: number;
height: number;
duration: number; // in seconds
frameRate: number;
codec: string;
}
export class VideoFileDecoder {
private info: DecodedVideoInfo | null = null;
private videoElement: HTMLVideoElement | null = null;
private info: DecodedVideoInfo | null = null;
private videoElement: HTMLVideoElement | null = null;
async loadVideo(videoUrl: string): Promise<DecodedVideoInfo> {
this.videoElement = document.createElement('video');
this.videoElement.src = videoUrl;
this.videoElement.preload = 'metadata';
async loadVideo(videoUrl: string): Promise<DecodedVideoInfo> {
this.videoElement = document.createElement("video");
this.videoElement.src = videoUrl;
this.videoElement.preload = "metadata";
return new Promise((resolve, reject) => {
this.videoElement!.addEventListener('loadedmetadata', () => {
const video = this.videoElement!;
this.info = {
width: video.videoWidth,
height: video.videoHeight,
duration: video.duration,
frameRate: 60,
codec: 'avc1.640033',
};
return new Promise((resolve, reject) => {
this.videoElement!.addEventListener("loadedmetadata", () => {
const video = this.videoElement!;
resolve(this.info);
});
this.info = {
width: video.videoWidth,
height: video.videoHeight,
duration: video.duration,
frameRate: 60,
codec: "avc1.640033",
};
this.videoElement!.addEventListener('error', (e) => {
reject(new Error(`Failed to load video: ${e}`));
});
});
}
resolve(this.info);
});
/**
* Get video element for seeking
*/
getVideoElement(): HTMLVideoElement | null {
return this.videoElement;
}
this.videoElement!.addEventListener("error", (e) => {
reject(new Error(`Failed to load video: ${e}`));
});
});
}
getInfo(): DecodedVideoInfo | null {
return this.info;
}
/**
* Get video element for seeking
*/
getVideoElement(): HTMLVideoElement | null {
return this.videoElement;
}
destroy(): void {
if (this.videoElement) {
this.videoElement.pause();
this.videoElement.src = '';
this.videoElement = null;
}
}
getInfo(): DecodedVideoInfo | null {
return this.info;
}
destroy(): void {
if (this.videoElement) {
this.videoElement.pause();
this.videoElement.src = "";
this.videoElement = null;
}
}
}
+308 -295
View File
@@ -1,344 +1,357 @@
import type { ExportConfig, ExportProgress, ExportResult } from './types';
import { StreamingVideoDecoder } from './streamingDecoder';
import { FrameRenderer } from './frameRenderer';
import { VideoMuxer } from './muxer';
import { AudioProcessor } from './audioEncoder';
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion } from '@/components/video-editor/types';
import type {
AnnotationRegion,
CropRegion,
SpeedRegion,
TrimRegion,
ZoomRegion,
} from "@/components/video-editor/types";
import { AudioProcessor } from "./audioEncoder";
import { FrameRenderer } from "./frameRenderer";
import { VideoMuxer } from "./muxer";
import { StreamingVideoDecoder } from "./streamingDecoder";
import type { ExportConfig, ExportProgress, ExportResult } from "./types";
interface VideoExporterConfig extends ExportConfig {
videoUrl: string;
wallpaper: string;
zoomRegions: ZoomRegion[];
trimRegions?: TrimRegion[];
speedRegions?: SpeedRegion[];
showShadow: boolean;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled?: boolean;
borderRadius?: number;
padding?: number;
videoPadding?: number;
cropRegion: CropRegion;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
onProgress?: (progress: ExportProgress) => void;
videoUrl: string;
wallpaper: string;
zoomRegions: ZoomRegion[];
trimRegions?: TrimRegion[];
speedRegions?: SpeedRegion[];
showShadow: boolean;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled?: boolean;
borderRadius?: number;
padding?: number;
videoPadding?: number;
cropRegion: CropRegion;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
onProgress?: (progress: ExportProgress) => void;
}
export class VideoExporter {
private config: VideoExporterConfig;
private streamingDecoder: StreamingVideoDecoder | null = null;
private renderer: FrameRenderer | null = null;
private encoder: VideoEncoder | null = null;
private muxer: VideoMuxer | null = null;
private audioProcessor: AudioProcessor | null = null;
private cancelled = false;
private encodeQueue = 0;
// Increased queue size for better throughput with hardware encoding
private readonly MAX_ENCODE_QUEUE = 120;
private videoDescription: Uint8Array | undefined;
private videoColorSpace: VideoColorSpaceInit | undefined;
// Track muxing promises for parallel processing
private muxingPromises: Promise<void>[] = [];
private chunkCount = 0;
private config: VideoExporterConfig;
private streamingDecoder: StreamingVideoDecoder | null = null;
private renderer: FrameRenderer | null = null;
private encoder: VideoEncoder | null = null;
private muxer: VideoMuxer | null = null;
private audioProcessor: AudioProcessor | null = null;
private cancelled = false;
private encodeQueue = 0;
// Increased queue size for better throughput with hardware encoding
private readonly MAX_ENCODE_QUEUE = 120;
private videoDescription: Uint8Array | undefined;
private videoColorSpace: VideoColorSpaceInit | undefined;
// Track muxing promises for parallel processing
private muxingPromises: Promise<void>[] = [];
private chunkCount = 0;
constructor(config: VideoExporterConfig) {
this.config = config;
}
constructor(config: VideoExporterConfig) {
this.config = config;
}
async export(): Promise<ExportResult> {
try {
this.cleanup();
this.cancelled = false;
async export(): Promise<ExportResult> {
try {
this.cleanup();
this.cancelled = false;
// Initialize streaming decoder and load video metadata
this.streamingDecoder = new StreamingVideoDecoder();
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
// Initialize streaming decoder and load video metadata
this.streamingDecoder = new StreamingVideoDecoder();
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
// Initialize frame renderer
this.renderer = new FrameRenderer({
width: this.config.width,
height: this.config.height,
wallpaper: this.config.wallpaper,
zoomRegions: this.config.zoomRegions,
showShadow: this.config.showShadow,
shadowIntensity: this.config.shadowIntensity,
showBlur: this.config.showBlur,
motionBlurEnabled: this.config.motionBlurEnabled,
borderRadius: this.config.borderRadius,
padding: this.config.padding,
cropRegion: this.config.cropRegion,
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
});
await this.renderer.initialize();
// Initialize frame renderer
this.renderer = new FrameRenderer({
width: this.config.width,
height: this.config.height,
wallpaper: this.config.wallpaper,
zoomRegions: this.config.zoomRegions,
showShadow: this.config.showShadow,
shadowIntensity: this.config.shadowIntensity,
showBlur: this.config.showBlur,
motionBlurEnabled: this.config.motionBlurEnabled,
borderRadius: this.config.borderRadius,
padding: this.config.padding,
cropRegion: this.config.cropRegion,
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
});
await this.renderer.initialize();
// Initialize video encoder
await this.initializeEncoder();
// Initialize video encoder
await this.initializeEncoder();
// Initialize muxer (with audio if source has an audio track)
const hasAudio = videoInfo.hasAudio;
this.muxer = new VideoMuxer(this.config, hasAudio);
await this.muxer.initialize();
// Initialize muxer (with audio if source has an audio track)
const hasAudio = videoInfo.hasAudio;
this.muxer = new VideoMuxer(this.config, hasAudio);
await this.muxer.initialize();
// Calculate effective duration and frame count (excluding trim regions)
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions, this.config.speedRegions);
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
// Calculate effective duration and frame count (excluding trim regions)
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(
this.config.trimRegions,
this.config.speedRegions,
);
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
console.log('[VideoExporter] Original duration:', videoInfo.duration, 's');
console.log('[VideoExporter] Effective duration:', effectiveDuration, 's');
console.log('[VideoExporter] Total frames to export:', totalFrames);
console.log('[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)');
console.log("[VideoExporter] Original duration:", videoInfo.duration, "s");
console.log("[VideoExporter] Effective duration:", effectiveDuration, "s");
console.log("[VideoExporter] Total frames to export:", totalFrames);
console.log("[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)");
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
let frameIndex = 0;
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
let frameIndex = 0;
// Stream decode and process frames — no seeking!
await this.streamingDecoder.decodeAll(
this.config.frameRate,
this.config.trimRegions,
this.config.speedRegions,
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
if (this.cancelled) {
videoFrame.close();
return;
}
// Stream decode and process frames — no seeking!
await this.streamingDecoder.decodeAll(
this.config.frameRate,
this.config.trimRegions,
this.config.speedRegions,
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
if (this.cancelled) {
videoFrame.close();
return;
}
const timestamp = frameIndex * frameDuration;
const timestamp = frameIndex * frameDuration;
// Render the frame with all effects using source timestamp
const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
await this.renderer!.renderFrame(videoFrame, sourceTimestampUs);
videoFrame.close();
// Render the frame with all effects using source timestamp
const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
await this.renderer!.renderFrame(videoFrame, sourceTimestampUs);
videoFrame.close();
const canvas = this.renderer!.getCanvas();
const canvas = this.renderer!.getCanvas();
// Create VideoFrame from canvas on GPU without reading pixels
// @ts-ignore - colorSpace not in TypeScript definitions but works at runtime
const exportFrame = new VideoFrame(canvas, {
timestamp,
duration: frameDuration,
colorSpace: {
primaries: 'bt709',
transfer: 'iec61966-2-1',
matrix: 'rgb',
fullRange: true,
},
});
// Create VideoFrame from canvas on GPU without reading pixels
// @ts-expect-error - colorSpace not in TypeScript definitions but works at runtime
const exportFrame = new VideoFrame(canvas, {
timestamp,
duration: frameDuration,
colorSpace: {
primaries: "bt709",
transfer: "iec61966-2-1",
matrix: "rgb",
fullRange: true,
},
});
// Check encoder queue before encoding to keep it full
while (this.encoder && this.encoder.encodeQueueSize >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
await new Promise(resolve => setTimeout(resolve, 5));
}
// Check encoder queue before encoding to keep it full
while (
this.encoder &&
this.encoder.encodeQueueSize >= this.MAX_ENCODE_QUEUE &&
!this.cancelled
) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
if (this.encoder && this.encoder.state === 'configured') {
this.encodeQueue++;
this.encoder.encode(exportFrame, { keyFrame: frameIndex % 150 === 0 });
} else {
console.warn(`[Frame ${frameIndex}] Encoder not ready! State: ${this.encoder?.state}`);
}
if (this.encoder && this.encoder.state === "configured") {
this.encodeQueue++;
this.encoder.encode(exportFrame, { keyFrame: frameIndex % 150 === 0 });
} else {
console.warn(`[Frame ${frameIndex}] Encoder not ready! State: ${this.encoder?.state}`);
}
exportFrame.close();
exportFrame.close();
frameIndex++;
frameIndex++;
// Update progress
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: frameIndex,
totalFrames,
percentage: (frameIndex / totalFrames) * 100,
estimatedTimeRemaining: 0,
});
}
}
);
// Update progress
if (this.config.onProgress) {
this.config.onProgress({
currentFrame: frameIndex,
totalFrames,
percentage: (frameIndex / totalFrames) * 100,
estimatedTimeRemaining: 0,
});
}
},
);
if (this.cancelled) {
return { success: false, error: 'Export cancelled' };
}
if (this.cancelled) {
return { success: false, error: "Export cancelled" };
}
// Finalize encoding
if (this.encoder && this.encoder.state === 'configured') {
await this.encoder.flush();
}
// Finalize encoding
if (this.encoder && this.encoder.state === "configured") {
await this.encoder.flush();
}
// Wait for all video muxing operations to complete
await Promise.all(this.muxingPromises);
// Wait for all video muxing operations to complete
await Promise.all(this.muxingPromises);
// Process audio track if present
if (hasAudio && !this.cancelled) {
const demuxer = this.streamingDecoder!.getDemuxer();
if (demuxer) {
console.log('[VideoExporter] Processing audio track...');
this.audioProcessor = new AudioProcessor();
await this.audioProcessor.process(demuxer, this.muxer!, this.config.trimRegions);
}
}
// Process audio track if present
if (hasAudio && !this.cancelled) {
const demuxer = this.streamingDecoder!.getDemuxer();
if (demuxer) {
console.log("[VideoExporter] Processing audio track...");
this.audioProcessor = new AudioProcessor();
await this.audioProcessor.process(demuxer, this.muxer!, this.config.trimRegions);
}
}
// Finalize muxer and get output blob
const blob = await this.muxer!.finalize();
// Finalize muxer and get output blob
const blob = await this.muxer!.finalize();
return { success: true, blob };
} catch (error) {
console.error('Export error:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
} finally {
this.cleanup();
}
}
return { success: true, blob };
} catch (error) {
console.error("Export error:", error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
} finally {
this.cleanup();
}
}
private async initializeEncoder(): Promise<void> {
this.encodeQueue = 0;
this.muxingPromises = [];
this.chunkCount = 0;
let videoDescription: Uint8Array | undefined;
private async initializeEncoder(): Promise<void> {
this.encodeQueue = 0;
this.muxingPromises = [];
this.chunkCount = 0;
let videoDescription: Uint8Array | undefined;
this.encoder = new VideoEncoder({
output: (chunk, meta) => {
// Capture decoder config metadata from encoder output
if (meta?.decoderConfig?.description && !videoDescription) {
const desc = meta.decoderConfig.description;
videoDescription = new Uint8Array(desc instanceof ArrayBuffer ? desc : (desc as any));
this.videoDescription = videoDescription;
}
// Capture colorSpace from encoder metadata if provided
if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) {
this.videoColorSpace = meta.decoderConfig.colorSpace;
}
this.encoder = new VideoEncoder({
output: (chunk, meta) => {
// Capture decoder config metadata from encoder output
if (meta?.decoderConfig?.description && !videoDescription) {
const desc = meta.decoderConfig.description;
videoDescription = new Uint8Array(desc instanceof ArrayBuffer ? desc : (desc as any));
this.videoDescription = videoDescription;
}
// Capture colorSpace from encoder metadata if provided
if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) {
this.videoColorSpace = meta.decoderConfig.colorSpace;
}
// Stream chunk to muxer immediately (parallel processing)
const isFirstChunk = this.chunkCount === 0;
this.chunkCount++;
// Stream chunk to muxer immediately (parallel processing)
const isFirstChunk = this.chunkCount === 0;
this.chunkCount++;
const muxingPromise = (async () => {
try {
if (isFirstChunk && this.videoDescription) {
// Add decoder config for the first chunk
const colorSpace = this.videoColorSpace || {
primaries: 'bt709',
transfer: 'iec61966-2-1',
matrix: 'rgb',
fullRange: true,
};
const muxingPromise = (async () => {
try {
if (isFirstChunk && this.videoDescription) {
// Add decoder config for the first chunk
const colorSpace = this.videoColorSpace || {
primaries: "bt709",
transfer: "iec61966-2-1",
matrix: "rgb",
fullRange: true,
};
const metadata: EncodedVideoChunkMetadata = {
decoderConfig: {
codec: this.config.codec || 'avc1.640033',
codedWidth: this.config.width,
codedHeight: this.config.height,
description: this.videoDescription,
colorSpace,
},
};
const metadata: EncodedVideoChunkMetadata = {
decoderConfig: {
codec: this.config.codec || "avc1.640033",
codedWidth: this.config.width,
codedHeight: this.config.height,
description: this.videoDescription,
colorSpace,
},
};
await this.muxer!.addVideoChunk(chunk, metadata);
} else {
await this.muxer!.addVideoChunk(chunk, meta);
}
} catch (error) {
console.error('Muxing error:', error);
}
})();
await this.muxer!.addVideoChunk(chunk, metadata);
} else {
await this.muxer!.addVideoChunk(chunk, meta);
}
} catch (error) {
console.error("Muxing error:", error);
}
})();
this.muxingPromises.push(muxingPromise);
this.encodeQueue--;
},
error: (error) => {
console.error('[VideoExporter] Encoder error:', error);
// Stop export encoding failed
this.cancelled = true;
},
});
this.muxingPromises.push(muxingPromise);
this.encodeQueue--;
},
error: (error) => {
console.error("[VideoExporter] Encoder error:", error);
// Stop export encoding failed
this.cancelled = true;
},
});
const codec = this.config.codec || 'avc1.640033';
const codec = this.config.codec || "avc1.640033";
const encoderConfig: VideoEncoderConfig = {
codec,
width: this.config.width,
height: this.config.height,
bitrate: this.config.bitrate,
framerate: this.config.frameRate,
latencyMode: 'quality', // Changed from 'realtime' to 'quality' for better throughput
bitrateMode: 'variable',
hardwareAcceleration: 'prefer-hardware',
};
const encoderConfig: VideoEncoderConfig = {
codec,
width: this.config.width,
height: this.config.height,
bitrate: this.config.bitrate,
framerate: this.config.frameRate,
latencyMode: "quality", // Changed from 'realtime' to 'quality' for better throughput
bitrateMode: "variable",
hardwareAcceleration: "prefer-hardware",
};
// Check hardware support first
const hardwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
// Check hardware support first
const hardwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
if (hardwareSupport.supported) {
// Use hardware encoding
console.log('[VideoExporter] Using hardware acceleration');
this.encoder.configure(encoderConfig);
} else {
// Fall back to software encoding
console.log('[VideoExporter] Hardware not supported, using software encoding');
encoderConfig.hardwareAcceleration = 'prefer-software';
if (hardwareSupport.supported) {
// Use hardware encoding
console.log("[VideoExporter] Using hardware acceleration");
this.encoder.configure(encoderConfig);
} else {
// Fall back to software encoding
console.log("[VideoExporter] Hardware not supported, using software encoding");
encoderConfig.hardwareAcceleration = "prefer-software";
const softwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
if (!softwareSupport.supported) {
throw new Error('Video encoding not supported on this system');
}
const softwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
if (!softwareSupport.supported) {
throw new Error("Video encoding not supported on this system");
}
this.encoder.configure(encoderConfig);
}
}
this.encoder.configure(encoderConfig);
}
}
cancel(): void {
this.cancelled = true;
if (this.streamingDecoder) {
this.streamingDecoder.cancel();
}
if (this.audioProcessor) {
this.audioProcessor.cancel();
}
this.cleanup();
}
cancel(): void {
this.cancelled = true;
if (this.streamingDecoder) {
this.streamingDecoder.cancel();
}
if (this.audioProcessor) {
this.audioProcessor.cancel();
}
this.cleanup();
}
private cleanup(): void {
if (this.encoder) {
try {
if (this.encoder.state === 'configured') {
this.encoder.close();
}
} catch (e) {
console.warn('Error closing encoder:', e);
}
this.encoder = null;
}
private cleanup(): void {
if (this.encoder) {
try {
if (this.encoder.state === "configured") {
this.encoder.close();
}
} catch (e) {
console.warn("Error closing encoder:", e);
}
this.encoder = null;
}
if (this.streamingDecoder) {
try {
this.streamingDecoder.destroy();
} catch (e) {
console.warn('Error destroying streaming decoder:', e);
}
this.streamingDecoder = null;
}
if (this.streamingDecoder) {
try {
this.streamingDecoder.destroy();
} catch (e) {
console.warn("Error destroying streaming decoder:", e);
}
this.streamingDecoder = null;
}
if (this.renderer) {
try {
this.renderer.destroy();
} catch (e) {
console.warn('Error destroying renderer:', e);
}
this.renderer = null;
}
if (this.renderer) {
try {
this.renderer.destroy();
} catch (e) {
console.warn("Error destroying renderer:", e);
}
this.renderer = null;
}
this.audioProcessor = null;
this.muxer = null;
this.encodeQueue = 0;
this.muxingPromises = [];
this.chunkCount = 0;
this.videoDescription = undefined;
this.videoColorSpace = undefined;
}
this.audioProcessor = null;
this.muxer = null;
this.encodeQueue = 0;
this.muxingPromises = [];
this.chunkCount = 0;
this.videoDescription = undefined;
this.videoColorSpace = undefined;
}
}
+94 -80
View File
@@ -1,125 +1,139 @@
export const SHORTCUT_ACTIONS = [
'addZoom',
'addTrim',
'addSpeed',
'addAnnotation',
'addKeyframe',
'deleteSelected',
'playPause',
"addZoom",
"addTrim",
"addSpeed",
"addAnnotation",
"addKeyframe",
"deleteSelected",
"playPause",
] as const;
export type ShortcutAction = (typeof SHORTCUT_ACTIONS)[number];
export interface ShortcutBinding {
key: string;
/** Maps to Cmd on macOS, Ctrl on Windows/Linux */
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
key: string;
/** Maps to Cmd on macOS, Ctrl on Windows/Linux */
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
}
export type ShortcutsConfig = Record<ShortcutAction, ShortcutBinding>;
export interface FixedShortcut {
label: string;
display: string;
bindings: ShortcutBinding[];
label: string;
display: string;
bindings: ShortcutBinding[];
}
export const FIXED_SHORTCUTS: FixedShortcut[] = [
{ label: 'Cycle Annotations Forward', display: 'Tab', bindings: [{ key: 'tab' }] },
{ label: 'Cycle Annotations Backward', display: 'Shift + Tab', bindings: [{ key: 'tab', shift: true }] },
{ label: 'Delete Selected (alt)', display: 'Del / ⌫', bindings: [{ key: 'delete' }, { key: 'backspace' }] },
{ label: 'Pan Timeline', display: 'Shift + Ctrl + Scroll', bindings: [] },
{ label: 'Zoom Timeline', display: 'Ctrl + Scroll', bindings: [] },
{ label: "Cycle Annotations Forward", display: "Tab", bindings: [{ key: "tab" }] },
{
label: "Cycle Annotations Backward",
display: "Shift + Tab",
bindings: [{ key: "tab", shift: true }],
},
{
label: "Delete Selected (alt)",
display: "Del / ⌫",
bindings: [{ key: "delete" }, { key: "backspace" }],
},
{ label: "Pan Timeline", display: "Shift + Ctrl + Scroll", bindings: [] },
{ label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] },
];
export type ShortcutConflict =
| { type: 'configurable'; action: ShortcutAction }
| { type: 'fixed'; label: string };
| { type: "configurable"; action: ShortcutAction }
| { type: "fixed"; label: string };
export function bindingsEqual(a: ShortcutBinding, b: ShortcutBinding): boolean {
return (
a.key.toLowerCase() === b.key.toLowerCase() &&
!!a.ctrl === !!b.ctrl &&
!!a.shift === !!b.shift &&
!!a.alt === !!b.alt
);
return (
a.key.toLowerCase() === b.key.toLowerCase() &&
!!a.ctrl === !!b.ctrl &&
!!a.shift === !!b.shift &&
!!a.alt === !!b.alt
);
}
export function findConflict(
binding: ShortcutBinding,
forAction: ShortcutAction,
config: ShortcutsConfig,
binding: ShortcutBinding,
forAction: ShortcutAction,
config: ShortcutsConfig,
): ShortcutConflict | null {
for (const fixed of FIXED_SHORTCUTS) {
if (fixed.bindings.some((b) => bindingsEqual(b, binding))) {
return { type: 'fixed', label: fixed.label };
}
}
for (const action of SHORTCUT_ACTIONS) {
if (action !== forAction && bindingsEqual(config[action], binding)) {
return { type: 'configurable', action };
}
}
return null;
for (const fixed of FIXED_SHORTCUTS) {
if (fixed.bindings.some((b) => bindingsEqual(b, binding))) {
return { type: "fixed", label: fixed.label };
}
}
for (const action of SHORTCUT_ACTIONS) {
if (action !== forAction && bindingsEqual(config[action], binding)) {
return { type: "configurable", action };
}
}
return null;
}
export const DEFAULT_SHORTCUTS: ShortcutsConfig = {
addZoom: { key: 'z' },
addTrim: { key: 't' },
addSpeed: { key: 's' },
addAnnotation: { key: 'a' },
addKeyframe: { key: 'f' },
deleteSelected: { key: 'd', ctrl: true },
playPause: { key: ' ' },
addZoom: { key: "z" },
addTrim: { key: "t" },
addSpeed: { key: "s" },
addAnnotation: { key: "a" },
addKeyframe: { key: "f" },
deleteSelected: { key: "d", ctrl: true },
playPause: { key: " " },
};
export const SHORTCUT_LABELS: Record<ShortcutAction, string> = {
addZoom: 'Add Zoom',
addTrim: 'Add Trim',
addSpeed: 'Add Speed',
addAnnotation: 'Add Annotation',
addKeyframe: 'Add Keyframe',
deleteSelected: 'Delete Selected',
playPause: 'Play / Pause',
addZoom: "Add Zoom",
addTrim: "Add Trim",
addSpeed: "Add Speed",
addAnnotation: "Add Annotation",
addKeyframe: "Add Keyframe",
deleteSelected: "Delete Selected",
playPause: "Play / Pause",
};
export function matchesShortcut(
e: KeyboardEvent,
binding: ShortcutBinding,
isMacPlatform: boolean,
e: KeyboardEvent,
binding: ShortcutBinding,
isMacPlatform: boolean,
): boolean {
if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false;
if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false;
const primaryMod = isMacPlatform ? e.metaKey : e.ctrlKey;
if (primaryMod !== !!binding.ctrl) return false;
if (e.shiftKey !== !!binding.shift) return false;
if (e.altKey !== !!binding.alt) return false;
const primaryMod = isMacPlatform ? e.metaKey : e.ctrlKey;
if (primaryMod !== !!binding.ctrl) return false;
if (e.shiftKey !== !!binding.shift) return false;
if (e.altKey !== !!binding.alt) return false;
return true;
return true;
}
const KEY_LABELS: Record<string, string> = {
' ': 'Space', 'delete': 'Del', 'backspace': '⌫', 'escape': 'Esc',
'arrowup': '↑', 'arrowdown': '↓', 'arrowleft': '←', 'arrowright': '→',
" ": "Space",
delete: "Del",
backspace: "⌫",
escape: "Esc",
arrowup: "↑",
arrowdown: "↓",
arrowleft: "←",
arrowright: "→",
};
export function formatBinding(binding: ShortcutBinding, isMac: boolean): string {
const parts: string[] = [];
if (binding.ctrl) parts.push(isMac ? '⌘' : 'Ctrl');
if (binding.shift) parts.push(isMac ? '⇧' : 'Shift');
if (binding.alt) parts.push(isMac ? '⌥' : 'Alt');
parts.push(KEY_LABELS[binding.key] ?? binding.key.toUpperCase());
return parts.join(' + ');
const parts: string[] = [];
if (binding.ctrl) parts.push(isMac ? "⌘" : "Ctrl");
if (binding.shift) parts.push(isMac ? "⇧" : "Shift");
if (binding.alt) parts.push(isMac ? "⌥" : "Alt");
parts.push(KEY_LABELS[binding.key] ?? binding.key.toUpperCase());
return parts.join(" + ");
}
export function mergeWithDefaults(partial: Partial<ShortcutsConfig>): ShortcutsConfig {
const merged = { ...DEFAULT_SHORTCUTS };
for (const action of SHORTCUT_ACTIONS) {
if (partial[action]) {
merged[action] = partial[action] as ShortcutBinding;
}
}
return merged;
const merged = { ...DEFAULT_SHORTCUTS };
for (const action of SHORTCUT_ACTIONS) {
if (partial[action]) {
merged[action] = partial[action] as ShortcutBinding;
}
}
return merged;
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
+10 -10
View File
@@ -1,10 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+32 -26
View File
@@ -1,6 +1,6 @@
export const ASPECT_RATIOS = ['16:9', '9:16', '1:1', '4:3', '4:5', '16:10', '10:16'] as const;
export const ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "4:5", "16:10", "10:16"] as const;
export type AspectRatio = typeof ASPECT_RATIOS[number];
export type AspectRatio = (typeof ASPECT_RATIOS)[number];
/**
* Returns the numeric value of an aspect ratio.
@@ -8,38 +8,44 @@ export type AspectRatio = typeof ASPECT_RATIOS[number];
* If TypeScript errors here, a new ratio was added to the type but not handled.
*/
export function getAspectRatioValue(aspectRatio: AspectRatio): number {
switch (aspectRatio) {
case '16:9': return 16 / 9;
case '9:16': return 9 / 16;
case '1:1': return 1;
case '4:3': return 4 / 3;
case '4:5': return 4 / 5;
case '16:10': return 16 / 10;
case '10:16': return 10 / 16;
default: {
// Ensures all cases are handled - TypeScript errors if missing
const _exhaustiveCheck: never = aspectRatio;
return _exhaustiveCheck;
}
}
switch (aspectRatio) {
case "16:9":
return 16 / 9;
case "9:16":
return 9 / 16;
case "1:1":
return 1;
case "4:3":
return 4 / 3;
case "4:5":
return 4 / 5;
case "16:10":
return 16 / 10;
case "10:16":
return 10 / 16;
default: {
// Ensures all cases are handled - TypeScript errors if missing
const _exhaustiveCheck: never = aspectRatio;
return _exhaustiveCheck;
}
}
}
export function getAspectRatioDimensions(
aspectRatio: AspectRatio,
baseWidth: number
aspectRatio: AspectRatio,
baseWidth: number,
): { width: number; height: number } {
const ratio = getAspectRatioValue(aspectRatio);
return {
width: baseWidth,
height: baseWidth / ratio,
};
const ratio = getAspectRatioValue(aspectRatio);
return {
width: baseWidth,
height: baseWidth / ratio,
};
}
export function getAspectRatioLabel(aspectRatio: AspectRatio): string {
return aspectRatio;
return aspectRatio;
}
export function formatAspectRatioForCSS(aspectRatio: AspectRatio): string {
return aspectRatio.replace(':', '/');
return aspectRatio.replace(":", "/");
}
+29 -29
View File
@@ -4,44 +4,44 @@ let cachedPlatform: string | null = null;
* Gets the current platform from Electron
*/
const getPlatform = async (): Promise<string> => {
if (cachedPlatform) return cachedPlatform;
try {
const platform = await window.electronAPI.getPlatform();
cachedPlatform = platform;
return platform;
} catch (error) {
console.warn('Failed to get platform from Electron, falling back to navigator:', error);
// Fallback for development/testing
let fallbackPlatform = 'win32';
if (typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform)) {
fallbackPlatform = 'darwin';
}
cachedPlatform = fallbackPlatform;
return fallbackPlatform;
}
if (cachedPlatform) return cachedPlatform;
try {
const platform = await window.electronAPI.getPlatform();
cachedPlatform = platform;
return platform;
} catch (error) {
console.warn("Failed to get platform from Electron, falling back to navigator:", error);
// Fallback for development/testing
let fallbackPlatform = "win32";
if (typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform)) {
fallbackPlatform = "darwin";
}
cachedPlatform = fallbackPlatform;
return fallbackPlatform;
}
};
/**
* Detects if the current platform is macOS
*/
export const isMac = async (): Promise<boolean> => {
const platform = await getPlatform();
return platform === 'darwin';
const platform = await getPlatform();
return platform === "darwin";
};
/**
* Gets the modifier key symbol based on the platform
*/
export const getModifierKey = async (): Promise<string> => {
return (await isMac()) ? '⌘' : 'Ctrl';
return (await isMac()) ? "⌘" : "Ctrl";
};
/**
* Gets the shift key symbol based on the platform
*/
export const getShiftKey = async (): Promise<string> => {
return (await isMac()) ? '⇧' : 'Shift';
return (await isMac()) ? "⇧" : "Shift";
};
/**
@@ -49,12 +49,12 @@ export const getShiftKey = async (): Promise<string> => {
* @param keys Array of key combinations (e.g., ['mod', 'D'] or ['shift', 'mod', 'Scroll'])
*/
export const formatShortcut = async (keys: string[]): Promise<string> => {
const isMacPlatform = await isMac();
return keys
.map(key => {
if (key.toLowerCase() === 'mod') return isMacPlatform ? '⌘' : 'Ctrl';
if (key.toLowerCase() === 'shift') return isMacPlatform ? '⇧' : 'Shift';
return key;
})
.join(' + ');
};
const isMacPlatform = await isMac();
return keys
.map((key) => {
if (key.toLowerCase() === "mod") return isMacPlatform ? "⌘" : "Ctrl";
if (key.toLowerCase() === "shift") return isMacPlatform ? "⇧" : "Shift";
return key;
})
.join(" + ");
};
+95 -85
View File
@@ -1,85 +1,95 @@
/// <reference types="vite/client" />
/// <reference types="../electron/electron-env" />
interface ProcessedDesktopSource {
id: string;
name: string;
display_id: string;
thumbnail: string | null;
appIcon: string | null;
}
interface CursorTelemetryPoint {
timeMs: number;
cx: number;
cy: number;
}
interface Window {
electronAPI: {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>
switchToEditor: () => Promise<void>
openSourceSelector: () => Promise<void>
selectSource: (source: any) => Promise<any>
getSelectedSource: () => Promise<any>
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{
success: boolean
path?: string
message: string
error?: string
}>
getRecordedVideoPath: () => Promise<{
success: boolean
path?: string
message?: string
error?: string
}>
getAssetBasePath: () => Promise<string | null>
setRecordingState: (recording: boolean) => Promise<void>
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean
samples: CursorTelemetryPoint[]
message?: string
error?: string
}>
onStopRecordingFromTray: (callback: () => void) => () => void
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{
success: boolean
path?: string
message?: string
canceled?: boolean
}>
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>
clearCurrentVideoPath: () => Promise<{ success: boolean }>
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{
success: boolean
path?: string
message?: string
canceled?: boolean
error?: string
}>
loadProjectFile: () => Promise<{
success: boolean
path?: string
project?: unknown
message?: string
canceled?: boolean
error?: string
}>
loadCurrentProjectFile: () => Promise<{
success: boolean
path?: string
project?: unknown
message?: string
canceled?: boolean
error?: string
}>
onMenuLoadProject: (callback: () => void) => () => void
onMenuSaveProject: (callback: () => void) => () => void
onMenuSaveProjectAs: (callback: () => void) => () => void
setMicrophoneExpanded: (expanded: boolean) => void
}
}
/// <reference types="vite/client" />
/// <reference types="../electron/electron-env" />
interface ProcessedDesktopSource {
id: string;
name: string;
display_id: string;
thumbnail: string | null;
appIcon: string | null;
}
interface CursorTelemetryPoint {
timeMs: number;
cx: number;
cy: number;
}
interface Window {
electronAPI: {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
openSourceSelector: () => Promise<void>;
selectSource: (source: any) => Promise<any>;
getSelectedSource: () => Promise<any>;
storeRecordedVideo: (
videoData: ArrayBuffer,
fileName: string,
) => Promise<{
success: boolean;
path?: string;
message: string;
error?: string;
}>;
getRecordedVideoPath: () => Promise<{
success: boolean;
path?: string;
message?: string;
error?: string;
}>;
getAssetBasePath: () => Promise<string | null>;
setRecordingState: (recording: boolean) => Promise<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
samples: CursorTelemetryPoint[];
message?: string;
error?: string;
}>;
onStopRecordingFromTray: (callback: () => void) => () => void;
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
saveExportedVideo: (
videoData: ArrayBuffer,
fileName: string,
) => Promise<{
success: boolean;
path?: string;
message?: string;
canceled?: boolean;
}>;
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
saveProjectFile: (
projectData: unknown,
suggestedName?: string,
existingProjectPath?: string,
) => Promise<{
success: boolean;
path?: string;
message?: string;
canceled?: boolean;
error?: string;
}>;
loadProjectFile: () => Promise<{
success: boolean;
path?: string;
project?: unknown;
message?: string;
canceled?: boolean;
error?: string;
}>;
loadCurrentProjectFile: () => Promise<{
success: boolean;
path?: string;
project?: unknown;
message?: string;
canceled?: boolean;
error?: string;
}>;
onMenuLoadProject: (callback: () => void) => () => void;
onMenuSaveProject: (callback: () => void) => () => void;
onMenuSaveProjectAs: (callback: () => void) => () => void;
setMicrophoneExpanded: (expanded: boolean) => void;
};
}
+69 -72
View File
@@ -1,74 +1,71 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
}
}
},
plugins: [require("tailwindcss-animate")],
}
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
1: "hsl(var(--chart-1))",
2: "hsl(var(--chart-2))",
3: "hsl(var(--chart-3))",
4: "hsl(var(--chart-4))",
5: "hsl(var(--chart-5))",
},
},
},
},
plugins: [require("tailwindcss-animate")],
};
+30 -30
View File
@@ -1,30 +1,30 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "electron"],
"exclude": ["**/*.test.ts", "**/*.test.tsx"],
"references": [{ "path": "./tsconfig.node.json" }]
}
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "electron"],
"exclude": ["**/*.test.ts", "**/*.test.tsx"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11 -11
View File
@@ -1,11 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+56 -58
View File
@@ -1,61 +1,59 @@
import { defineConfig } from 'vite'
import path from 'node:path'
import electron from 'vite-plugin-electron/simple'
import react from '@vitejs/plugin-react'
import path from "node:path";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import electron from "vite-plugin-electron/simple";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
electron({
main: {
// Shortcut of `build.lib.entry`.
entry: 'electron/main.ts',
vite: {
build: {
}
}
},
preload: {
// Shortcut of `build.rollupOptions.input`.
// Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`.
input: path.join(__dirname, 'electron/preload.ts'),
},
// Ployfill the Electron and Node.js API for Renderer process.
// If you want use Node.js in Renderer process, the `nodeIntegration` needs to be enabled in the Main process.
// See https://github.com/electron-vite/vite-plugin-electron-renderer
renderer: process.env.NODE_ENV === 'test'
// https://github.com/electron-vite/vite-plugin-electron-renderer/issues/78#issuecomment-2053600808
? undefined
: {},
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
build: {
target: 'esnext',
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.debug']
}
},
rollupOptions: {
output: {
manualChunks: {
'pixi': ['pixi.js'],
'react-vendor': ['react', 'react-dom'],
'video-processing': ['mediabunny', 'mp4box', '@fix-webm-duration/fix']
}
}
},
chunkSizeWarningLimit: 1000
}
})
plugins: [
react(),
electron({
main: {
// Shortcut of `build.lib.entry`.
entry: "electron/main.ts",
vite: {
build: {},
},
},
preload: {
// Shortcut of `build.rollupOptions.input`.
// Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`.
input: path.join(__dirname, "electron/preload.ts"),
},
// Ployfill the Electron and Node.js API for Renderer process.
// If you want use Node.js in Renderer process, the `nodeIntegration` needs to be enabled in the Main process.
// See https://github.com/electron-vite/vite-plugin-electron-renderer
renderer:
process.env.NODE_ENV === "test"
? // https://github.com/electron-vite/vite-plugin-electron-renderer/issues/78#issuecomment-2053600808
undefined
: {},
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
build: {
target: "esnext",
minify: "terser",
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ["console.log", "console.debug"],
},
},
rollupOptions: {
output: {
manualChunks: {
pixi: ["pixi.js"],
"react-vendor": ["react", "react-dom"],
"video-processing": ["mediabunny", "mp4box", "@fix-webm-duration/fix"],
},
},
},
chunkSizeWarningLimit: 1000,
},
});
+13 -13
View File
@@ -1,15 +1,15 @@
import { defineConfig } from 'vitest/config'
import path from 'node:path'
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
})
test: {
globals: true,
environment: "node",
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
});