diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..0437c208 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,85 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +| Command | Description | +|---------|-------------| +| `npm run dev` | Start Vite dev server + Electron | +| `npm run build` | Full production build (native helpers → TypeScript → Vite → Electron Builder) | +| `npm run build:win` | Windows build (includes WGC capture) | +| `npm run build:mac` | macOS build | +| `npm run build:linux` | Linux build | +| `npm run lint` | Biome lint check | +| `npm run lint:fix` | Biome auto-fix | +| `npm run format` | Biome format | +| `npm run test` | Run Vitest (single run) | +| `npm run test:watch` | Vitest in watch mode | +| `npm run i18n:check` | Validate i18n locale file structure against `en` | + +Run a single test file: `npx vitest run src/lib/exporter/gifExporter.test.ts` + +## Architecture + +**Electron + React + Vite** desktop screen recorder and video editor. + +### Process Model + +- **Main process** (`electron/main.ts`): Window lifecycle, tray icon, native recording, file I/O, permissions, auto-cleanup +- **Preload** (`electron/preload.ts`): contextBridge API exposing ~40 IPC methods to renderer +- **IPC handlers** (`electron/ipc/handlers.ts`): All main↔renderer communication; recording pipelines, file operations, project persistence, shortcuts, cursor telemetry +- **Renderer** (`src/`): React SPA routed by `?windowType=` URL parameter (no router library) + +### Three Window Types + +1. **HUD Overlay** (`?windowType=hud-overlay`): 500×155 floating bar at screen bottom for recording controls. Transparent, always-on-top. +2. **Editor** (`?windowType=editor`): Main video editor window (1200×800, maximized). Houses timeline, playback, annotations, export. +3. **Source Selector** (`?windowType=source-selector`): 620×420 popup for picking capture sources. + +`App.tsx` switches components based on the `windowType` query param. + +### Recording Pipeline (Platform-Specific) + +- **macOS**: ScreenCaptureKit via compiled Swift helpers (`scripts/build-native-helpers.mjs`) +- **Windows**: WGC (Windows.Graphics.Capture) via C++ CMake build (`scripts/build-wgc-capture.mjs`), fallback to FFmpeg +- **Linux**: Chromium `getDisplayMedia` +- Core recording logic: `src/hooks/useScreenRecorder.ts` (60 FPS target, adaptive bitrate) + +### Export Engine (`src/lib/exporter/`) + +GPU-accelerated pipeline: WebCodec streaming decode → PIXI.js frame rendering (zoom, crop, annotations, cursor) → MP4 mux (mp4box) or GIF (gif.js with Web Workers). Key files: `videoExporter.ts`, `gifExporter.ts`, `frameRenderer.ts`, `streamingDecoder.ts`, `muxer.ts`. + +### State Management + +No external state library. `VideoEditor.tsx` uses ~50 `useState` hooks. Undo/redo via `useRef` history stacks. Only two React Contexts: `I18nContext` and `ShortcutsContext`. + +### i18n System + +- Config: `src/i18n/config.ts` — languages: `en` (source), `es` +- Locale files: `src/i18n/locales/{lang}/{namespace}.json` +- 7 namespaces: `common`, `launch`, `editor`, `timeline`, `settings`, `dialogs`, `shortcuts` +- Implementation: `src/contexts/I18nContext.tsx` — compile-time JSON import, recursive key lookup, `{{var}}` interpolation +- Fallback chain: current language → English → provided fallback → raw key +- Run `npm run i18n:check` after any locale changes +- See `TRANSLATION_GUIDE.md` for contributor workflow + +### UI Layer + +- Base components: `src/components/ui/` — Radix UI primitives wrapped with shadcn/ui patterns +- Styling: Tailwind CSS with CSS variable theming, class-based dark mode +- Icons: lucide-react + react-icons +- Timeline: `dnd-timeline` library for drag-and-drop editing +- Path alias: `@/` → `src/` + +### Project File Format + +`.recordly` files (legacy `.openscreen` supported). Serialization in `src/components/video-editor/projectPersistence.ts`. + +## Code Quality + +- **Biome** is the primary linter/formatter (tab indent, LF, 100 char width) +- **Strict TypeScript**: `noExplicitAny: error`, no unused variables/params +- **Hook rules enforced**: `useHookAtTopLevel: error` +- **Import organization**: automatic via Biome +- Tests use Vitest + fast-check (property-based testing). Test files: `src/**/*.{test,spec}.{ts,tsx}` diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 0aa8ff5e..20d121b0 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -9,6 +9,7 @@ import { RxDragHandleDots2 } from "react-icons/rx"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; +import { useScopedT } from "../../contexts/I18nContext"; import { Button } from "../ui/button"; import { AudioLevelMeter } from "../ui/audio-level-meter"; import { ContentClamp } from "../ui/content-clamp"; @@ -25,6 +26,7 @@ import styles from "./LaunchWindow.module.css"; export function LaunchWindow() { const { locale, setLocale } = useI18n(); + const t = useScopedT('launch'); const LOCALE_LABELS: Record = { en: "EN", es: "ES", "zh-CN": "中文" }; const { @@ -224,7 +226,7 @@ export function LaunchWindow() { size="icon" onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)} disabled={recording} - title={systemAudioEnabled ? "Disable system audio" : "Enable system audio"} + title={systemAudioEnabled ? t('recording.disableSystemAudio') : t('recording.enableSystemAudio')} className="text-white/80 hover:bg-transparent" > {systemAudioEnabled ? : } @@ -234,7 +236,7 @@ export function LaunchWindow() { size="icon" onClick={toggleMicrophone} disabled={recording} - title={microphoneEnabled ? "Disable microphone" : "Enable microphone"} + title={microphoneEnabled ? t('recording.disableMicrophone') : t('recording.enableMicrophone')} className="text-white/80 hover:bg-transparent" > {microphoneEnabled ? : } @@ -258,7 +260,7 @@ export function LaunchWindow() { ) : ( <> - Record + {t('recording.record')} )} @@ -268,10 +270,10 @@ export function LaunchWindow() { size="sm" onClick={chooseRecordingsDirectory} disabled={recording} - title={recordingsDirectory ? `Recording folder: ${recordingsDirectory}` : "Choose recordings folder"} + title={recordingsDirectory ? t('recording.recordingFolder', undefined, { path: recordingsDirectory }) : t('recording.chooseRecordingsFolder')} className={`text-white/75 hover:bg-transparent px-1 text-[11px] underline decoration-white/45 underline-offset-2 ${styles.electronNoDrag}`} > - {`Path: /${recordingsDirectoryName}/`} + {t('recording.folderPath', undefined, { name: recordingsDirectoryName })}
@@ -281,7 +283,7 @@ export function LaunchWindow() { size="icon" onClick={openVideoFile} disabled={recording} - title="Open video file" + title={t('recording.openVideoFile')} className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`} > @@ -291,7 +293,7 @@ export function LaunchWindow() { size="icon" onClick={openProjectFile} disabled={recording} - title="Open project" + title={t('recording.openProject')} className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`} > @@ -330,7 +332,7 @@ export function LaunchWindow() { variant="link" size="icon" onClick={sendHudOverlayHide} - title="Hide HUD" + title={t('recording.hideHud')} className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`} > @@ -339,7 +341,7 @@ export function LaunchWindow() { variant="link" size="icon" onClick={sendHudOverlayClose} - title="Close App" + title={t('recording.closeApp')} className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`} > diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index 0de404bb..9b98e329 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -3,6 +3,7 @@ import { Button } from "../ui/button"; import { MdCheck } from "react-icons/md"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { Card } from "../ui/card"; +import { useScopedT } from "../../contexts/I18nContext"; import styles from "./SourceSelector.module.css"; interface DesktopSource { @@ -50,6 +51,7 @@ function parseSourceMetadata(source: ProcessedDesktopSource) { } export function SourceSelector() { + const t = useScopedT('launch'); const [sources, setSources] = useState([]); const [selectedSource, setSelectedSource] = useState(null); const [activeTab, setActiveTab] = useState<'screens' | 'windows'>('screens'); @@ -118,7 +120,7 @@ export function SourceSelector() {
-

Loading sources...

+

{t('sourceSelector.loadingSources')}

); @@ -130,10 +132,10 @@ export function SourceSelector() { setActiveTab(value as 'screens' | 'windows')}> - Screens ({screenSources.length}) + {t('sourceSelector.screens')} ({screenSources.length}) - Windows ({windowSources.length}) + {t('sourceSelector.windows')} ({windowSources.length})
@@ -171,7 +173,7 @@ export function SourceSelector() {
-

Only visible (non-minimized) windows can be recorded.

+

{t('sourceSelector.windowsNote')}

{windowSources.length === 0 && (
No windows available
@@ -202,7 +204,7 @@ export function SourceSelector() { ) : (
)} -
Window
+
{t('sourceSelector.windowPlaceholder')}
)} {selectedSource?.id === source.id && ( @@ -233,8 +235,8 @@ export function SourceSelector() {
- - + +
diff --git a/src/components/video-editor/AddCustomFontDialog.tsx b/src/components/video-editor/AddCustomFontDialog.tsx index a60644fa..f0054a7f 100644 --- a/src/components/video-editor/AddCustomFontDialog.tsx +++ b/src/components/video-editor/AddCustomFontDialog.tsx @@ -19,12 +19,14 @@ import { isValidGoogleFontsUrl, type CustomFont, } from '@/lib/customFonts'; +import { useScopedT } from '../../contexts/I18nContext'; interface AddCustomFontDialogProps { onFontAdded?: (font: CustomFont) => void; } export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { + const t = useScopedT('dialogs'); const [open, setOpen] = useState(false); const [importUrl, setImportUrl] = useState(''); const [fontName, setFontName] = useState(''); @@ -45,17 +47,17 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { const handleAdd = async () => { // Validate inputs if (!importUrl.trim()) { - toast.error('Please enter a Google Fonts import URL'); + toast.error(t('addFont.enterUrl')); return; } if (!isValidGoogleFontsUrl(importUrl)) { - toast.error('Please enter a valid Google Fonts URL'); + toast.error(t('addFont.invalidUrl')); return; } if (!fontName.trim()) { - toast.error('Please enter a font name'); + toast.error(t('addFont.enterName')); return; } @@ -65,7 +67,7 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { // Extract font family from URL const fontFamily = parseFontFamilyFromImport(importUrl); if (!fontFamily) { - toast.error('Could not extract font family from URL'); + toast.error(t('addFont.extractFailed')); setLoading(false); return; } @@ -86,7 +88,7 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { onFontAdded(newFont); } - toast.success(`Font "${fontName}" added successfully`); + toast.success(t('addFont.addSuccess', undefined, { name: fontName })); // Reset and close setImportUrl(''); @@ -95,10 +97,10 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { } 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', { + toast.error(t('addFont.addFailed'), { 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.', + ? t('addFont.loadTimeout') + : t('addFont.loadFailed'), }); } finally { setLoading(false); @@ -114,47 +116,47 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10 h-9 text-xs" > - Add Google Font + {t('addFont.title')} - Add Google Font + {t('addFont.heading')} - Add a custom font from Google Fonts to use in your annotations. + {t('addFont.description')}
handleImportUrlChange(e.target.value)} className="bg-white/5 border-white/10 text-slate-200" />

- Get this from Google Fonts: Select a font → Click "Get font" → Copy the @import URL + {t('addFont.urlHelp')}

setFontName(e.target.value)} className="bg-white/5 border-white/10 text-slate-200" />

- This is how the font will appear in the font selector + {t('addFont.nameHelp')}

@@ -164,14 +166,14 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { onClick={() => setOpen(false)} className="bg-white/5 border-white/10 text-slate-200 hover:bg-white/10" > - Cancel + {t('addFont.cancel')}
@@ -179,4 +181,4 @@ export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) { ); } - + diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index 4590b421..fd8d9a82 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, useEffect } from "react"; +import { useRef, useState, useEffect, useMemo } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { Trash2, Type, Image as ImageIcon, Upload, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, ChevronDown, Info } from "lucide-react"; @@ -13,6 +13,7 @@ import { cn } from "@/lib/utils"; import { getArrowComponent } from "./ArrowSvgs"; import { AddCustomFontDialog } from "./AddCustomFontDialog"; import { getCustomFonts, type CustomFont } from "@/lib/customFonts"; +import { useScopedT } from "../../contexts/I18nContext"; interface AnnotationSettingsPanelProps { annotation: AnnotationRegion; @@ -23,15 +24,15 @@ interface AnnotationSettingsPanelProps { onDelete: () => void; } -const FONT_FAMILIES = [ - { value: 'system-ui, -apple-system, sans-serif', label: 'Classic' }, - { value: 'Georgia, serif', label: 'Editor' }, - { value: 'Impact, Arial Black, sans-serif', label: 'Strong' }, - { value: 'Courier New, monospace', label: 'Typewriter' }, - { value: 'Brush Script MT, cursive', label: 'Deco' }, - { value: 'Arial, sans-serif', label: 'Simple' }, - { value: 'Verdana, sans-serif', label: 'Modern' }, - { value: 'Trebuchet MS, sans-serif', label: 'Clean' }, +const FONT_FAMILY_VALUES = [ + { value: 'system-ui, -apple-system, sans-serif', labelKey: 'fontStyles.classic' }, + { value: 'Georgia, serif', labelKey: 'fontStyles.editor' }, + { value: 'Impact, Arial Black, sans-serif', labelKey: 'fontStyles.strong' }, + { value: 'Courier New, monospace', labelKey: 'fontStyles.typewriter' }, + { value: 'Brush Script MT, cursive', labelKey: 'fontStyles.deco' }, + { value: 'Arial, sans-serif', labelKey: 'fontStyles.simple' }, + { value: 'Verdana, sans-serif', labelKey: 'fontStyles.modern' }, + { value: 'Trebuchet MS, sans-serif', labelKey: 'fontStyles.clean' }, ]; const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128]; @@ -44,9 +45,15 @@ export function AnnotationSettingsPanel({ onFigureDataChange, onDelete, }: AnnotationSettingsPanelProps) { + const t = useScopedT('editor'); const fileInputRef = useRef(null); const [customFonts, setCustomFonts] = useState([]); + const fontFamilies = useMemo(() => + FONT_FAMILY_VALUES.map((f) => ({ value: f.value, label: t(f.labelKey) })), + [t], + ); + // Load custom fonts on mount useEffect(() => { setCustomFonts(getCustomFonts()); @@ -82,8 +89,8 @@ export function AnnotationSettingsPanel({ // Validate file type const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; if (!validTypes.includes(file.type)) { - toast.error('Invalid file type', { - description: 'Please upload a JPG, PNG, GIF, or WebP image file.', + toast.error(t('annotations.imageUploadError'), { + description: t('annotations.imageUploadErrorDescription'), }); event.target.value = ''; return; @@ -95,13 +102,13 @@ export function AnnotationSettingsPanel({ const dataUrl = e.target?.result as string; if (dataUrl) { onContentChange(dataUrl); - toast.success('Image uploaded successfully!'); + toast.success(t('annotations.imageUploadSuccess')); } }; reader.onerror = () => { - toast.error('Failed to upload image', { - description: 'There was an error reading the file.', + toast.error(t('annotations.imageUploadFailed'), { + description: t('annotations.imageUploadFailedDescription'), }); }; @@ -113,9 +120,9 @@ export function AnnotationSettingsPanel({
- Annotation Settings + {t('annotations.settings')} - Active + {t('annotations.active')}
@@ -124,28 +131,28 @@ export function AnnotationSettingsPanel({ - Text + {t('annotations.text')} - Image + {t('annotations.image')} - Arrow + {t('annotations.arrow')} {/* Text Content */}
- +