Merge branch 'main' into feat/turkish-locale

This commit is contained in:
Sid
2026-04-07 22:30:16 -07:00
committed by GitHub
34 changed files with 2580 additions and 238 deletions
+13
View File
@@ -31,6 +31,19 @@ jobs:
- run: npm ci
- run: npx tsc --noEmit
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:browser:install
- run: npm run test:browser
build:
name: Build
runs-on: ubuntu-latest
+4 -1
View File
@@ -29,4 +29,7 @@ release/**
# Playwright
test-results
playwright-report/
playwright-report/
# Vitest browser mode screenshots
__screenshots__/
+2
View File
@@ -26,6 +26,8 @@ interface Window {
electronAPI: {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
switchToHud: () => Promise<void>;
startNewRecording: () => Promise<{ success: boolean; error?: string }>;
openSourceSelector: () => Promise<void>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
+5 -2
View File
@@ -5,10 +5,12 @@ import commonEn from "../src/i18n/locales/en/common.json";
import dialogsEn from "../src/i18n/locales/en/dialogs.json";
import commonEs from "../src/i18n/locales/es/common.json";
import dialogsEs from "../src/i18n/locales/es/dialogs.json";
import commonFr from "../src/i18n/locales/fr/common.json";
import dialogsFr from "../src/i18n/locales/fr/dialogs.json";
import commonZh from "../src/i18n/locales/zh-CN/common.json";
import dialogsZh from "../src/i18n/locales/zh-CN/dialogs.json";
type Locale = "en" | "zh-CN" | "es";
type Locale = "en" | "zh-CN" | "es" | "fr";
type Namespace = "common" | "dialogs";
type MessageMap = Record<string, unknown>;
@@ -16,12 +18,13 @@ const messages: Record<Locale, Record<Namespace, MessageMap>> = {
en: { common: commonEn, dialogs: dialogsEn },
"zh-CN": { common: commonZh, dialogs: dialogsZh },
es: { common: commonEs, dialogs: dialogsEs },
fr: { common: commonFr, dialogs: dialogsFr },
};
let currentLocale: Locale = "en";
export function setMainLocale(locale: string) {
if (locale === "en" || locale === "zh-CN" || locale === "es") {
if (locale === "en" || locale === "zh-CN" || locale === "es" || locale === "fr") {
currentLocale = locale;
}
}
+17
View File
@@ -355,7 +355,24 @@ export function registerIpcHandlers(
getMainWindow: () => BrowserWindow | null,
getSourceSelectorWindow: () => BrowserWindow | null,
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
switchToHud?: () => void,
) {
ipcMain.handle("switch-to-hud", () => {
if (switchToHud) switchToHud();
});
ipcMain.handle("start-new-recording", async () => {
try {
setCurrentRecordingSessionState(null);
if (switchToHud) {
switchToHud();
}
return { success: true };
} catch (error) {
console.error("Failed to start new recording:", error);
return { success: false, error: String(error) };
}
});
ipcMain.handle("get-sources", async (_, opts) => {
const sources = await desktopCapturer.getSources(opts);
return sources.map((source) => ({
+11
View File
@@ -371,6 +371,16 @@ app.whenReady().then(async () => {
// Ensure recordings directory exists
await ensureRecordingsDir();
function switchToHudWrapper() {
if (mainWindow) {
isForceClosing = true;
mainWindow.close();
isForceClosing = false;
mainWindow = null;
}
showMainWindow();
}
registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
@@ -384,6 +394,7 @@ app.whenReady().then(async () => {
showMainWindow();
}
},
switchToHudWrapper,
);
createWindow();
});
+6
View File
@@ -18,6 +18,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
switchToEditor: () => {
return ipcRenderer.invoke("switch-to-editor");
},
switchToHud: () => {
return ipcRenderer.invoke("switch-to-hud");
},
startNewRecording: () => {
return ipcRenderer.invoke("start-new-recording");
},
openSourceSelector: () => {
return ipcRenderer.invoke("open-source-selector");
},
+1679 -201
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -26,6 +26,8 @@
"test": "vitest --run",
"test:watch": "vitest",
"build-vite": "tsc && vite build",
"test:browser": "vitest --config vitest.browser.config.ts --run",
"test:browser:install": "playwright install --with-deps chromium-headless-shell",
"test:e2e": "playwright test",
"prepare": "husky"
},
@@ -73,7 +75,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@playwright/test": "^1.58.2",
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -82,6 +84,8 @@
"@types/react-dom": "^18.2.21",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/browser": "^4.0.16",
"@vitest/browser-playwright": "^4.0.16",
"autoprefixer": "^10.4.21",
"electron": "^39.2.7",
"electron-builder": "^26.7.0",
+88 -2
View File
@@ -55,7 +55,70 @@ import type {
ZoomDepth,
ZoomFocusMode,
} from "./types";
import { SPEED_OPTIONS } from "./types";
import { MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types";
function CustomSpeedInput({
value,
onChange,
onError,
}: {
value: number;
onChange: (val: number) => void;
onError: () => void;
}) {
const isPreset = SPEED_OPTIONS.some((o) => o.speed === value);
const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value)));
const [isFocused, setIsFocused] = useState(false);
const prevValue = useRef(value);
if (!isFocused && prevValue.current !== value) {
prevValue.current = value;
setDraft(isPreset ? "" : String(Math.round(value)));
}
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const digits = e.target.value.replace(/\D/g, "");
if (digits === "") {
setDraft("");
return;
}
const num = Number(digits);
if (num > MAX_PLAYBACK_SPEED) {
onError();
return;
}
setDraft(digits);
if (num >= 1) onChange(num);
},
[onChange, onError],
);
const handleBlur = useCallback(() => {
setIsFocused(false);
if (!draft || Number(draft) < 1) {
setDraft(isPreset ? "" : String(Math.round(value)));
}
}, [draft, isPreset, value]);
return (
<div className="flex items-center gap-1">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
placeholder="--"
value={draft}
onFocus={() => setIsFocused(true)}
onChange={handleChange}
onBlur={handleBlur}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-[#d97706] text-center focus:outline-none focus:border-[#d97706]/40"
/>
<span className="text-[11px] font-semibold text-slate-500">×</span>
</div>
);
}
const WALLPAPER_COUNT = 18;
const WALLPAPER_RELATIVE = Array.from(
@@ -584,7 +647,7 @@ export function SettingsPanel({
</span>
)}
</div>
<div className="grid grid-cols-7 gap-1.5">
<div className="grid grid-cols-5 gap-1.5">
{SPEED_OPTIONS.map((option) => {
const isActive = selectedSpeedValue === option.speed;
return (
@@ -609,6 +672,29 @@ export function SettingsPanel({
);
})}
</div>
<div className="mt-3">
<div className="flex items-center justify-between">
<span
className={cn("text-[11px]", selectedSpeedId ? "text-slate-500" : "text-slate-600")}
>
{t("speed.customPlaybackSpeed")}
</span>
{selectedSpeedId ? (
<CustomSpeedInput
value={selectedSpeedValue ?? 1}
onChange={(val) => onSpeedChange?.(val)}
onError={() => toast.error(t("speed.maxSpeedError"))}
/>
) : (
<div className="flex items-center gap-1 opacity-40">
<div className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-slate-600 text-center">
--
</div>
<span className="text-[11px] font-semibold text-slate-600">×</span>
</div>
)}
</div>
</div>
{!selectedSpeedId && (
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("speed.selectRegion")}</p>
)}
+56 -1
View File
@@ -1,8 +1,16 @@
import type { Span } from "dnd-timeline";
import { FolderOpen, Languages, Save } from "lucide-react";
import { FolderOpen, Languages, Save, Video } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
@@ -117,6 +125,7 @@ export default function VideoEditor() {
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false);
const [exportQuality, setExportQuality] = useState<ExportQuality>("good");
const [exportFormat, setExportFormat] = useState<ExportFormat>("mp4");
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(15);
@@ -501,6 +510,16 @@ export default function VideoEditor() {
await saveProject(true);
}, [saveProject]);
const handleNewRecordingConfirm = useCallback(async () => {
const result = await window.electronAPI.startNewRecording();
if (result.success) {
setShowNewRecordingDialog(false);
} else {
console.error("Failed to start new recording:", result.error);
setError("Failed to start new recording: " + (result.error || "Unknown error"));
}
}, []);
const handleLoadProject = useCallback(async () => {
const result = await window.electronAPI.loadProjectFile();
@@ -1482,6 +1501,34 @@ export default function VideoEditor() {
return (
<div className="flex flex-col h-screen bg-[#09090b] text-slate-200 overflow-hidden selection:bg-[#34B27B]/30">
<Dialog open={showNewRecordingDialog} onOpenChange={setShowNewRecordingDialog}>
<DialogContent
className="sm:max-w-[425px]"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<DialogHeader>
<DialogTitle>{t("newRecording.title")}</DialogTitle>
<DialogDescription>{t("newRecording.description")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<button
type="button"
onClick={() => setShowNewRecordingDialog(false)}
className="px-4 py-2 rounded-md bg-white/10 text-white hover:bg-white/20 text-sm font-medium transition-colors"
>
{t("newRecording.cancel")}
</button>
<button
type="button"
onClick={handleNewRecordingConfirm}
className="px-4 py-2 rounded-md bg-[#34B27B] text-white hover:bg-[#34B27B]/90 text-sm font-medium transition-colors"
>
{t("newRecording.confirm")}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
<div
className="h-10 flex-shrink-0 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-between px-6 z-50"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
@@ -1507,6 +1554,14 @@ export default function VideoEditor() {
))}
</select>
</div>
<button
type="button"
onClick={() => setShowNewRecordingDialog(true)}
className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium"
>
<Video size={14} />
{t("newRecording.title")}
</button>
<button
type="button"
onClick={handleLoadProject}
@@ -5,6 +5,7 @@ import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
import {
type AnnotationRegion,
type CropRegion,
clampPlaybackSpeed,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
@@ -15,6 +16,8 @@ import {
DEFAULT_WEBCAM_MASK_SHAPE,
DEFAULT_WEBCAM_POSITION,
DEFAULT_ZOOM_DEPTH,
MAX_PLAYBACK_SPEED,
MIN_PLAYBACK_SPEED,
type SpeedRegion,
type TrimRegion,
type WebcamLayoutPreset,
@@ -223,14 +226,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
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
isFiniteNumber(region.speed) &&
region.speed >= MIN_PLAYBACK_SPEED &&
region.speed <= MAX_PLAYBACK_SPEED
? clampPlaybackSpeed(region.speed)
: DEFAULT_PLAYBACK_SPEED;
return {
+13 -1
View File
@@ -138,7 +138,16 @@ export const DEFAULT_CROP_REGION: CropRegion = {
height: 1,
};
export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;
export type PlaybackSpeed = number;
export const MIN_PLAYBACK_SPEED = 0.1;
// Anything above 16x causes the playhead to stall during preview
// due to the video decoder not being able to keep up.
export const MAX_PLAYBACK_SPEED = 16;
export function clampPlaybackSpeed(speed: number): PlaybackSpeed {
return Math.round(Math.min(MAX_PLAYBACK_SPEED, Math.max(MIN_PLAYBACK_SPEED, speed)) * 100) / 100;
}
export interface SpeedRegion {
id: string;
@@ -155,6 +164,9 @@ export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [
{ speed: 1.5, label: "1.5×" },
{ speed: 1.75, label: "1.75×" },
{ speed: 2, label: "2×" },
{ speed: 3, label: "3×" },
{ speed: 4, label: "4×" },
{ speed: 5, label: "5×" },
];
export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5;
+1 -1
View File
@@ -1,5 +1,5 @@
export const DEFAULT_LOCALE = "en" as const;
export const SUPPORTED_LOCALES = ["en", "zh-CN", "es", "tr"] as const;
export const SUPPORTED_LOCALES = ["en", "zh-CN", "es", , "fr", "tr"] as const;
export const I18N_NAMESPACES = [
"common",
"dialogs",
+8 -5
View File
@@ -27,10 +27,11 @@
"triggerLabel": "How trimming works",
"title": "How Trimming Works",
"description": "Understanding how to cut out unwanted parts of your video.",
"explanation": "The Trim tool works by defining the segments you want to",
"explanationRemove": "remove",
"explanationCovered": "covered",
"explanationEnd": "by a red trim segment will be cut out when you export.",
"explanationBefore": "The Trim tool works by defining the segments you want to",
"remove": "remove",
"explanationMiddle": " — anything",
"covered": "covered",
"explanationAfter": "by a red trim segment will be cut out when you export.",
"visualExample": "Visual Example",
"removed": "REMOVED",
"kept": "Kept",
@@ -39,7 +40,9 @@
"part3": "Part 3",
"finalVideo": "Final Video",
"step1Title": "1. Add Trim",
"step1Description": "Press T or click the scissors icon to mark a section for removal.",
"step1DescriptionBefore": "Press ",
"step1DescriptionAfter": " or click the scissors icon to mark a section for removal.",
"step2Title": "2. Adjust",
"step2Description": "Drag the edges of the red region to cover exactly what you want to cut out."
},
+6
View File
@@ -1,4 +1,10 @@
{
"newRecording": {
"title": "Return to Recorder",
"description": "Your current session has been saved.",
"cancel": "Cancel",
"confirm": "Confirm"
},
"errors": {
"noVideoLoaded": "No video loaded",
"videoNotReady": "Video not ready",
+3 -1
View File
@@ -13,7 +13,9 @@
"speed": {
"playbackSpeed": "Playback Speed",
"selectRegion": "Select a speed region to adjust",
"deleteRegion": "Delete Speed Region"
"deleteRegion": "Delete Speed Region",
"customPlaybackSpeed": "Custom Playback Speed",
"maxSpeedError": "Speed can't go higher than 16×"
},
"trim": {
"deleteRegion": "Delete Trim Region"
+7 -5
View File
@@ -27,10 +27,11 @@
"triggerLabel": "Cómo funciona el recorte",
"title": "Cómo funciona el recorte",
"description": "Aprende a eliminar las partes no deseadas de tu video.",
"explanation": "La herramienta de recorte funciona definiendo los segmentos que deseas",
"explanationRemove": "eliminar",
"explanationCovered": "cubierto",
"explanationEnd": "por un segmento rojo de recorte será eliminado al exportar.",
"explanationBefore": "La herramienta de recorte funciona definiendo los segmentos que deseas",
"remove": "eliminar",
"explanationMiddle": " — cualquier parte",
"covered": "cubierta",
"explanationAfter": "por un segmento rojo será eliminada al exportar.",
"visualExample": "Ejemplo visual",
"removed": "ELIMINADO",
"kept": "Conservado",
@@ -39,7 +40,8 @@
"part3": "Parte 3",
"finalVideo": "Video final",
"step1Title": "1. Agregar recorte",
"step1Description": "Presiona T o haz clic en el ícono de tijeras para marcar una sección a eliminar.",
"step1DescriptionBefore": "Presiona ",
"step1DescriptionAfter": " o haz clic en el ícono de tijeras para marcar una sección a eliminar.",
"step2Title": "2. Ajustar",
"step2Description": "Arrastra los bordes de la región roja para cubrir exactamente lo que deseas eliminar."
},
+3 -1
View File
@@ -13,7 +13,9 @@
"speed": {
"playbackSpeed": "Velocidad de reproducción",
"selectRegion": "Selecciona una región de velocidad para ajustar",
"deleteRegion": "Eliminar región de velocidad"
"deleteRegion": "Eliminar región de velocidad",
"customPlaybackSpeed": "Velocidad personalizada",
"maxSpeedError": "La velocidad no puede superar 16×"
},
"trim": {
"deleteRegion": "Eliminar región de recorte"
+29
View File
@@ -0,0 +1,29 @@
{
"actions": {
"cancel": "Annuler",
"save": "Enregistrer",
"delete": "Supprimer",
"close": "Fermer",
"share": "Partager",
"done": "Terminer",
"open": "Ouvrir",
"upload": "Téléverser",
"export": "Exporter",
"file": "Fichier",
"edit": "Éditer",
"view": "Affichage",
"window": "Fenêtre",
"quit": "Quitter",
"stopRecording": "Arrêter l'enregistrement"
},
"playback": {
"play": "Lecture",
"pause": "Pause",
"fullscreen": "Plein écran",
"exitFullscreen": "Quitter le plein écran"
},
"locale": {
"name": "Français",
"short": "FR"
}
}
+68
View File
@@ -0,0 +1,68 @@
{
"export": {
"complete": "Export terminé",
"yourFormatReady": "Votre {{format}} est prêt",
"showInFolder": "Afficher dans le dossier",
"finalizingVideo": "Finalisation de l'export vidéo...",
"compilingGifProgress": "Compilation du GIF... {{progress}}%",
"compilingGifWait": "Compilation du GIF... Cela peut prendre un moment",
"takeMoment": "Cela peut prendre un moment...",
"failed": "Export échoué",
"tryAgain": "Veuillez réessayer",
"finalizingVideoTitle": "Finalisation de la vidéo",
"compilingGif": "Compilation du GIF",
"exportingFormat": "Export de {{format}}",
"compiling": "Compilation en cours",
"renderingFrames": "Rendu des images",
"processing": "Traitement en cours...",
"finalizing": "Finalisation...",
"compilingStatus": "Compilation...",
"status": "Statut",
"format": "Format",
"frames": "Images",
"cancelExport": "Annuler l'export",
"savedSuccessfully": "{{format}} enregistré avec succès !"
},
"tutorial": {
"triggerLabel": "Comment fonctionne la coupe",
"title": "Comment fonctionne la coupe",
"description": "Comprendre comment supprimer les parties indésirables de votre vidéo.",
"explanation": "L'outil Coupe fonctionne en définissant les segments que vous souhaitez",
"explanationRemove": "supprimer",
"explanationCovered": "couvert",
"explanationEnd": "par un segment de coupe rouge sera coupé lors de l'export.",
"visualExample": "Exemple visuel",
"removed": "SUPPRIMÉ",
"kept": "Conservé",
"part1": "Partie 1",
"part2": "Partie 2",
"part3": "Partie 3",
"finalVideo": "Vidéo finale",
"step1Title": "1. Ajouter une coupe",
"step1Description": "Appuyez sur T ou cliquez sur l'icône ciseaux pour marquer une section à supprimer.",
"step2Title": "2. Ajuster",
"step2Description": "Faites glisser les bords de la région rouge pour couvrir exactement ce que vous souhaitez couper."
},
"unsavedChanges": {
"title": "Modifications non enregistrées",
"message": "Vous avez des modifications non enregistrées.",
"detail": "Voulez-vous enregistrer votre projet avant de fermer ?",
"saveAndClose": "Enregistrer et fermer",
"discardAndClose": "Ignorer et fermer",
"loadProject": "Charger un projet…",
"saveProject": "Enregistrer le projet…",
"saveProjectAs": "Enregistrer le projet sous…"
},
"fileDialogs": {
"saveGif": "Enregistrer le GIF exporté",
"saveVideo": "Enregistrer la vidéo exportée",
"selectVideo": "Sélectionner un fichier vidéo",
"saveProject": "Enregistrer le projet OpenScreen",
"openProject": "Ouvrir un projet OpenScreen",
"gifImage": "Image GIF",
"mp4Video": "Vidéo MP4",
"videoFiles": "Fichiers vidéo",
"openscreenProject": "Projet OpenScreen",
"allFiles": "Tous les fichiers"
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"errors": {
"noVideoLoaded": "Aucune vidéo chargée",
"videoNotReady": "Vidéo non prête",
"unableToDetermineSourcePath": "Impossible de déterminer le chemin de la vidéo source",
"failedToSaveGif": "Échec de l'enregistrement du GIF",
"gifExportFailed": "L'export du GIF a échoué",
"failedToSaveVideo": "Échec de l'enregistrement de la vidéo",
"exportFailed": "L'export a échoué",
"exportFailedWithError": "L'export a échoué : {{error}}",
"failedToSaveExport": "Échec de l'enregistrement de l'export",
"failedToSaveExportedVideo": "Échec de l'enregistrement de la vidéo exportée",
"failedToRevealInFolder": "Erreur lors de l'affichage dans le dossier : {{error}}"
},
"export": {
"canceled": "Export annulé",
"exportedSuccessfully": "{{format}} exporté avec succès"
},
"project": {
"saveCanceled": "Enregistrement du projet annulé",
"failedToSave": "Échec de l'enregistrement du projet",
"savedTo": "Projet enregistré dans {{path}}",
"failedToLoad": "Échec du chargement du projet",
"invalidFormat": "Format de fichier projet invalide",
"loadedFrom": "Projet chargé depuis {{path}}"
},
"recording": {
"failedCameraAccess": "Échec de la demande d'accès à la caméra.",
"cameraBlocked": "L'accès à la caméra est bloqué. Activez-le dans les paramètres système pour utiliser la webcam.",
"systemAudioUnavailable": "Audio système non disponible. Enregistrement sans audio système.",
"microphoneDenied": "Accès au microphone refusé. L'enregistrement continuera sans audio.",
"cameraDenied": "Accès à la caméra refusé. L'enregistrement continuera sans webcam.",
"permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran."
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"tooltips": {
"hideHUD": "Masquer le HUD",
"closeApp": "Fermer l'application",
"restartRecording": "Redémarrer l'enregistrement",
"cancelRecording": "Annuler l'enregistrement",
"pauseRecording": "Mettre en pause l'enregistrement",
"resumeRecording": "Reprendre l'enregistrement",
"openVideoFile": "Ouvrir un fichier vidéo",
"openProject": "Ouvrir un projet"
},
"audio": {
"enableSystemAudio": "Activer l'audio système",
"disableSystemAudio": "Désactiver l'audio système",
"enableMicrophone": "Activer le microphone",
"disableMicrophone": "Désactiver le microphone",
"defaultMicrophone": "Microphone par défaut"
},
"webcam": {
"enableWebcam": "Activer la webcam",
"disableWebcam": "Désactiver la webcam",
"defaultCamera": "Caméra par défaut",
"searching": "Recherche en cours...",
"noneFound": "Aucune caméra trouvée",
"unavailable": "Caméra non disponible"
},
"sourceSelector": {
"loading": "Chargement des sources...",
"screens": "Écrans ({{count}})",
"windows": "Fenêtres ({{count}})",
"defaultSourceName": "Écran"
},
"recording": {
"selectSource": "Veuillez sélectionner une source à enregistrer"
},
"language": "Langue"
}
+159
View File
@@ -0,0 +1,159 @@
{
"zoom": {
"level": "Niveau de zoom",
"selectRegion": "Sélectionnez une région de zoom à ajuster",
"deleteZoom": "Supprimer le zoom",
"focusMode": {
"title": "Mode focus",
"manual": "Manuel",
"auto": "Auto",
"autoDescription": "La caméra suit la position du curseur enregistré"
}
},
"speed": {
"playbackSpeed": "Vitesse de lecture",
"selectRegion": "Sélectionnez une région de vitesse à ajuster",
"deleteRegion": "Supprimer la région de vitesse"
},
"trim": {
"deleteRegion": "Supprimer la région de coupe"
},
"layout": {
"title": "Mise en page",
"preset": "Préréglage",
"selectPreset": "Choisir un préréglage",
"pictureInPicture": "Incrustation d'image",
"verticalStack": "Empilement vertical",
"webcamShape": "Forme de la caméra"
},
"effects": {
"title": "Effets vidéo",
"blurBg": "Flou arrière-plan",
"motionBlur": "Flou de mouvement",
"off": "désactivé",
"shadow": "Ombre",
"roundness": "Arrondi",
"padding": "Marge"
},
"background": {
"title": "Arrière-plan",
"image": "Image",
"color": "Couleur",
"gradient": "Dégradé",
"uploadCustom": "Téléverser une image",
"gradientLabel": "Dégradé {{index}}"
},
"crop": {
"title": "Recadrage",
"cropVideo": "Recadrer la vidéo",
"dragInstruction": "Faites glisser chaque côté pour ajuster la zone de recadrage",
"ratio": "Ratio",
"free": "Libre",
"done": "Terminer",
"lockAspectRatio": "Verrouiller le ratio",
"unlockAspectRatio": "Déverrouiller le ratio"
},
"exportFormat": {
"mp4": "MP4",
"gif": "GIF",
"mp4Video": "Vidéo MP4",
"mp4Description": "Fichier vidéo haute qualité",
"gifAnimation": "Animation GIF",
"gifDescription": "Image animée pour le partage"
},
"exportQuality": {
"title": "Qualité d'export",
"low": "Faible",
"medium": "Moyenne",
"high": "Haute"
},
"gifSettings": {
"frameRate": "Fréquence d'images GIF",
"size": "Taille du GIF",
"loop": "GIF en boucle"
},
"project": {
"save": "Enregistrer le projet",
"load": "Charger un projet"
},
"export": {
"videoButton": "Exporter la vidéo",
"gifButton": "Exporter le GIF",
"chooseSaveLocation": "Choisir l'emplacement d'enregistrement"
},
"links": {
"reportBug": "Signaler un bug",
"starOnGithub": "Étoile sur GitHub"
},
"imageUpload": {
"invalidFileType": "Type de fichier invalide",
"jpgOnly": "Veuillez téléverser un fichier image JPG ou JPEG.",
"uploadSuccess": "Image personnalisée téléversée avec succès !",
"failedToUpload": "Échec du téléversement de l'image",
"errorReading": "Une erreur s'est produite lors de la lecture du fichier."
},
"annotation": {
"title": "Paramètres d'annotation",
"active": "Actif",
"typeText": "Texte",
"typeImage": "Image",
"typeArrow": "Flèche",
"textContent": "Contenu du texte",
"textPlaceholder": "Saisissez votre texte...",
"fontStyle": "Style de police",
"selectStyle": "Choisir un style",
"size": "Taille",
"customFonts": "Polices personnalisées",
"textColor": "Couleur du texte",
"background": "Arrière-plan",
"none": "Aucun",
"color": "Couleur",
"clearBackground": "Supprimer l'arrière-plan",
"uploadImage": "Téléverser une image",
"supportedFormats": "Formats supportés : JPG, PNG, GIF, WebP",
"arrowDirection": "Direction de la flèche",
"strokeWidth": "Épaisseur du trait : {{width}}px",
"arrowColor": "Couleur de la flèche",
"deleteAnnotation": "Supprimer l'annotation",
"shortcutsAndTips": "Raccourcis & Astuces",
"tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
"tipTabCycle": "Utilisez Tab pour cycler entre les éléments superposés.",
"tipShiftTabCycle": "Utilisez Shift+Tab pour cycler en sens inverse.",
"invalidImageType": "Type de fichier invalide",
"imageFormatsOnly": "Veuillez téléverser un fichier image JPG, PNG, GIF ou WebP.",
"imageUploadSuccess": "Image téléversée avec succès !",
"failedImageUpload": "Échec du téléversement de l'image"
},
"fontStyles": {
"classic": "Classique",
"editor": "Éditeur",
"strong": "Gras",
"typewriter": "Machine à écrire",
"deco": "Déco",
"simple": "Simple",
"modern": "Moderne",
"clean": "Épuré"
},
"customFont": {
"dialogTitle": "Ajouter une police Google",
"urlLabel": "URL d'import Google Fonts",
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
"urlHelp": "Obtenez-la depuis Google Fonts : Sélectionnez une police → Cliquez sur « Obtenir la police » → Copiez l'URL @import",
"nameLabel": "Nom d'affichage",
"namePlaceholder": "Ma police personnalisée",
"nameHelp": "C'est ainsi que la police apparaîtra dans le sélecteur de polices",
"addButton": "Ajouter la police",
"addingButton": "Ajout en cours...",
"errorEmptyUrl": "Veuillez saisir une URL d'import Google Fonts",
"errorInvalidUrl": "Veuillez saisir une URL Google Fonts valide",
"errorEmptyName": "Veuillez saisir un nom de police",
"errorExtractFailed": "Impossible d'extraire la famille de polices depuis l'URL",
"successMessage": "Police « {{fontName}} » ajoutée avec succès",
"failedToAdd": "Échec de l'ajout de la police",
"errorTimeout": "La police a mis trop de temps à charger. Vérifiez l'URL et réessayez.",
"errorLoadFailed": "La police n'a pas pu être chargée. Vérifiez que l'URL Google Fonts est correcte."
},
"language": {
"title": "Langue"
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"title": "Raccourcis clavier",
"customize": "Personnaliser",
"configurable": "Configurable",
"fixed": "Fixe",
"pressKey": "Appuyez sur une touche…",
"clickToChange": "Cliquez pour modifier",
"pressEscToCancel": "Appuyez sur Échap pour annuler",
"helpText": "Cliquez sur un raccourci puis appuyez sur la nouvelle combinaison de touches. Appuyez sur Échap pour annuler.",
"resetToDefaults": "Réinitialiser les valeurs par défaut",
"alreadyUsedBy": "Déjà utilisé par {{action}}",
"swap": "Échanger",
"reservedShortcut": "Ce raccourci est réservé pour « {{label}} » et ne peut pas être réassigné.",
"savedToast": "Raccourcis clavier enregistrés",
"resetToast": "Réinitialisé aux raccourcis par défaut — cliquez sur Enregistrer pour appliquer",
"actions": {
"addZoom": "Ajouter un zoom",
"addTrim": "Ajouter une coupe",
"addSpeed": "Ajouter une vitesse",
"addAnnotation": "Ajouter une annotation",
"addKeyframe": "Ajouter une image-clé",
"deleteSelected": "Supprimer la sélection",
"playPause": "Lecture / Pause"
},
"fixedActions": {
"undo": "Annuler",
"redo": "Rétablir",
"cycleAnnotationsForward": "Parcourir les annotations en avant",
"cycleAnnotationsBackward": "Parcourir les annotations en arrière",
"deleteSelectedAlt": "Supprimer la sélection (alt)",
"panTimeline": "Panoramique de la timeline",
"zoomTimeline": "Zoom de la timeline",
"frameBack": "Image précédente",
"frameForward": "Image suivante"
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"buttons": {
"addZoom": "Ajouter un zoom (Z)",
"suggestZooms": "Suggérer des zooms depuis le curseur",
"addTrim": "Ajouter une coupe (T)",
"addAnnotation": "Ajouter une annotation (A)",
"addSpeed": "Ajouter une vitesse (S)"
},
"hints": {
"pressZoom": "Appuyez sur Z pour ajouter un zoom",
"pressTrim": "Appuyez sur T pour ajouter une coupe",
"pressAnnotation": "Appuyez sur A pour ajouter une annotation",
"pressSpeed": "Appuyez sur S pour ajouter une vitesse"
},
"labels": {
"pan": "Panoramique",
"zoom": "Zoom",
"zoomItem": "Zoom {{index}}",
"trimItem": "Coupe {{index}}",
"speedItem": "Vitesse {{index}}",
"annotationItem": "Annotation",
"imageItem": "Image",
"emptyText": "Texte vide"
},
"emptyState": {
"noVideo": "Aucune vidéo chargée",
"dragAndDrop": "Glissez-déposez une vidéo pour commencer à éditer"
},
"errors": {
"cannotPlaceZoom": "Impossible de placer le zoom ici",
"zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
"zoomSuggestionUnavailable": "Gestionnaire de suggestions de zoom non disponible",
"noCursorTelemetry": "Aucune télémétrie de curseur disponible",
"noCursorTelemetryDescription": "Enregistrez d'abord un screencast pour générer des suggestions basées sur le curseur.",
"noUsableTelemetry": "Aucune télémétrie de curseur utilisable",
"noUsableTelemetryDescription": "L'enregistrement ne contient pas suffisamment de données de mouvement du curseur.",
"noDwellMoments": "Aucun moment de pause du curseur trouvé",
"noDwellMomentsDescription": "Essayez un enregistrement avec des pauses plus lentes du curseur sur les actions importantes.",
"noAutoZoomSlots": "Aucun emplacement de zoom automatique disponible",
"noAutoZoomSlotsDescription": "Les points de pause détectés chevauchent des régions de zoom existantes.",
"cannotPlaceTrim": "Impossible de placer la coupe ici",
"trimExistsAtLocation": "Une coupe existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
"cannotPlaceSpeed": "Impossible de placer la vitesse ici",
"speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l'espace disponible est insuffisant."
},
"success": {
"addedZoomSuggestions": "{{count}} suggestion de zoom basée sur le curseur ajoutée",
"addedZoomSuggestionsPlural": "{{count}} suggestions de zoom basées sur le curseur ajoutées"
}
}
+7 -5
View File
@@ -27,10 +27,11 @@
"triggerLabel": "剪辑功能说明",
"title": "剪辑功能说明",
"description": "了解如何剪掉视频中不需要的部分。",
"explanation": "剪辑工具通过定义您要",
"explanationRemove": "移除",
"explanationCovered": "覆盖",
"explanationEnd": "的片段来工作。被红色剪辑区域覆盖的部分将在导出时被剪掉。",
"explanationBefore": "剪辑工具通过定义您要",
"remove": "移除",
"explanationMiddle": "——任何被",
"covered": "覆盖",
"explanationAfter": "的红色剪辑区域部分将在导出时被剪掉。",
"visualExample": "示例演示",
"removed": "已移除",
"kept": "保留",
@@ -39,7 +40,8 @@
"part3": "第 3 部分",
"finalVideo": "最终视频",
"step1Title": "1. 添加剪辑",
"step1Description": "按 T 或点击剪刀图标来标记要移除的片段。",
"step1DescriptionBefore": "按",
"step1DescriptionAfter": "键或点击剪刀图标来标记要移除的片段。",
"step2Title": "2. 调整",
"step2Description": "拖动红色区域的边缘,精确覆盖您要剪掉的部分。"
},
+3 -1
View File
@@ -13,7 +13,9 @@
"speed": {
"playbackSpeed": "播放速度",
"selectRegion": "选择要调整的速度区域",
"deleteRegion": "删除速度区域"
"deleteRegion": "删除速度区域",
"customPlaybackSpeed": "自定义播放速度",
"maxSpeedError": "速度不能超过 16×"
},
"trim": {
"deleteRegion": "删除剪辑区域"
+49 -1
View File
@@ -112,6 +112,8 @@ export class FrameRenderer {
private shadowCtx: CanvasRenderingContext2D | null = null;
private compositeCanvas: HTMLCanvasElement | null = null;
private compositeCtx: CanvasRenderingContext2D | null = null;
private rasterCanvas: HTMLCanvasElement | null = null;
private rasterCtx: CanvasRenderingContext2D | null = null;
private config: FrameRenderConfig;
private animationState: AnimationState;
private layoutCache: LayoutCache | null = null;
@@ -191,6 +193,14 @@ export class FrameRenderer {
throw new Error("Failed to get 2D context for composite canvas");
}
this.rasterCanvas = document.createElement("canvas");
this.rasterCanvas.width = this.config.width;
this.rasterCanvas.height = this.config.height;
this.rasterCtx = this.rasterCanvas.getContext("2d");
if (!this.rasterCtx) {
throw new Error("Failed to get 2D context for raster canvas");
}
// Setup shadow canvas if needed
if (this.config.showShadow) {
this.shadowCanvas = document.createElement("canvas");
@@ -675,10 +685,46 @@ export class FrameRenderer {
);
}
// On Linux/Wayland the implicit GPU→2D texture-sharing path
// used by drawImage(webglCanvas) can fail silently (EGL/Ozone),
// producing green/empty frames. Explicit gl.readPixels always
// copies from GPU to CPU memory, bypassing that path.
private readbackVideoCanvas(): HTMLCanvasElement {
const glCanvas = this.app!.canvas as HTMLCanvasElement;
const gl =
(glCanvas.getContext("webgl2") as WebGL2RenderingContext | null) ??
(glCanvas.getContext("webgl") as WebGLRenderingContext | null);
if (!gl || !this.rasterCanvas || !this.rasterCtx) {
return glCanvas;
}
const w = glCanvas.width;
const h = glCanvas.height;
const buf = new Uint8Array(w * h * 4);
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf);
// readPixels returns rows bottom-to-top; flip vertically
const rowSize = w * 4;
const temp = new Uint8Array(rowSize);
for (let top = 0, bot = h - 1; top < bot; top++, bot--) {
const tOff = top * rowSize;
const bOff = bot * rowSize;
temp.set(buf.subarray(tOff, tOff + rowSize));
buf.copyWithin(tOff, bOff, bOff + rowSize);
buf.set(temp, bOff);
}
const imageData = new ImageData(new Uint8ClampedArray(buf.buffer), w, h);
this.rasterCtx.putImageData(imageData, 0, 0);
return this.rasterCanvas;
}
private compositeWithShadows(webcamFrame?: VideoFrame | null): void {
if (!this.compositeCanvas || !this.compositeCtx || !this.app) return;
const videoCanvas = this.app.canvas as HTMLCanvasElement;
const videoCanvas = this.readbackVideoCanvas();
const ctx = this.compositeCtx;
const w = this.compositeCanvas.width;
const h = this.compositeCanvas.height;
@@ -795,5 +841,7 @@ export class FrameRenderer {
this.shadowCtx = null;
this.compositeCanvas = null;
this.compositeCtx = null;
this.rasterCanvas = null;
this.rasterCtx = null;
}
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
import { GifExporter } from "./gifExporter";
import type { ExportProgress } from "./types";
describe("GifExporter (real browser)", () => {
it("exports a valid GIF blob from a real video", async () => {
const progressEvents: ExportProgress[] = [];
const exporter = new GifExporter({
videoUrl: sampleVideoUrl,
width: 320,
height: 180,
frameRate: 15,
loop: true,
sizePreset: "medium",
wallpaper: "#1a1a2e",
zoomRegions: [],
showShadow: false,
shadowIntensity: 0,
showBlur: false,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
onProgress: (p) => progressEvents.push(p),
});
const result = await exporter.export();
expect(result.success, result.error).toBe(true);
expect(result.blob).toBeInstanceOf(Blob);
const buf = await result.blob!.arrayBuffer();
const header = new TextDecoder().decode(new Uint8Array(buf, 0, 6));
expect(header).toMatch(/^GIF8[79]a/);
expect(result.blob!.size).toBeGreaterThan(1024);
expect(progressEvents.length).toBeGreaterThan(0);
const finalizing = progressEvents.filter((p) => p.phase === "finalizing");
expect(finalizing.length).toBeGreaterThan(0);
expect(finalizing.at(-1)!.percentage).toBe(100);
});
});
+57 -1
View File
@@ -3,6 +3,52 @@ import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
const SOURCE_LOAD_TIMEOUT_MS = 60_000;
/**
* Build a full WebCodecs-compatible AV1 codec string from the AV1CodecConfigurationRecord.
* web-demuxer may return a bare "av01" when the WASM-side parser fails to read
* the extradata (e.g. raw OBU sequence header from WebM instead of ISOBMFF av1C box).
* This function parses the record if present, otherwise returns a safe default.
*
* @see https://aomediacodec.github.io/av1-isobmff/#av1codecconfigurationbox-section
*/
function buildAV1CodecString(description?: BufferSource): string {
const fallback = "av01.0.01M.08";
if (!description) return fallback;
const bytes =
description instanceof ArrayBuffer
? new Uint8Array(description)
: new Uint8Array(description.buffer, description.byteOffset, description.byteLength);
// AV1CodecConfigurationRecord layout (4+ bytes):
// Byte 0: marker (1) | version (7)
// Byte 1: seq_profile (3) | seq_level_idx_0 (5)
// Byte 2: seq_tier_0 (1) | high_bitdepth (1) | twelve_bit (1) | ...
// The spec says version should be 1, but Chrome/Electron's MediaRecorder
// may write version 127 (0xFF first byte). We accept any version as long
// as the marker bit is set and the record is long enough.
if (bytes.length < 4) return fallback;
if (!(bytes[0] & 0x80)) return fallback; // marker bit must be 1
// Byte 1: seq_profile (3) | seq_level_idx_0 (5)
const profile = (bytes[1] >> 5) & 0x07;
const level = bytes[1] & 0x1f;
// Byte 2: seq_tier_0 (1) | high_bitdepth (1) | twelve_bit (1) | monochrome (1) | ...
const tier = (bytes[2] >> 7) & 0x01;
const highBitdepth = (bytes[2] >> 6) & 0x01;
const twelveBit = (bytes[2] >> 5) & 0x01;
let bitdepth = 8;
if (highBitdepth) bitdepth = twelveBit ? 12 : 10;
const tierChar = tier ? "H" : "M";
const levelStr = level.toString().padStart(2, "0");
const bitdepthStr = bitdepth.toString().padStart(2, "0");
return `av01.${profile}.${levelStr}${tierChar}.${bitdepthStr}`;
}
export interface DecodedVideoInfo {
width: number;
height: number;
@@ -183,7 +229,17 @@ export class StreamingVideoDecoder {
}
const decoderConfig = await this.demuxer.getDecoderConfig("video");
const codec = this.metadata.codec.toLowerCase();
// web-demuxer may return a bare "av01" for AV1 in WebM containers when the
// extradata isn't in the expected ISOBMFF format. WebCodecs requires the
// full parametrized form (e.g. "av01.0.05M.08").
if (/^av01$/i.test(decoderConfig.codec)) {
decoderConfig.codec = buildAV1CodecString(
decoderConfig.description as BufferSource | undefined,
);
}
const codec = decoderConfig.codec.toLowerCase();
const shouldPreferSoftwareDecode = codec.includes("av01") || codec.includes("av1");
const segments = this.splitBySpeed(
this.computeSegments(this.metadata.duration, trimRegions),
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
import type { ExportProgress } from "./types";
import { VideoExporter } from "./videoExporter";
describe("VideoExporter (real browser)", () => {
it("exports a valid MP4 blob from a real video", async () => {
const progressEvents: ExportProgress[] = [];
const exporter = new VideoExporter({
videoUrl: sampleVideoUrl,
width: 320,
height: 180,
frameRate: 15,
bitrate: 1_000_000,
wallpaper: "#1a1a2e",
zoomRegions: [],
showShadow: false,
shadowIntensity: 0,
showBlur: false,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
onProgress: (p) => progressEvents.push(p),
});
const result = await exporter.export();
expect(result.success, result.error).toBe(true);
expect(result.blob).toBeInstanceOf(Blob);
const buf = await result.blob!.arrayBuffer();
const bytes = new Uint8Array(buf);
const ftyp = new TextDecoder().decode(bytes.slice(4, 8));
expect(ftyp).toBe("ftyp");
expect(result.blob!.size).toBeGreaterThan(1024);
expect(progressEvents.length).toBeGreaterThan(0);
const finalizing = progressEvents.filter((p) => p.phase === "finalizing");
expect(finalizing.length).toBeGreaterThan(0);
expect(finalizing.at(-1)!.percentage).toBe(100);
});
});
+2
View File
@@ -19,6 +19,8 @@ interface Window {
electronAPI: {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
switchToHud: () => Promise<void>;
startNewRecording: () => Promise<{ success: boolean; error?: string }>;
openSourceSelector: () => Promise<void>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
+28
View File
@@ -0,0 +1,28 @@
import path from "node:path";
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.browser.test.{ts,tsx}"],
browser: {
enabled: true,
provider: playwright({
launch: {
// Software WebGL so Pixi.js works in headless CI without a GPU.
args: ["--enable-unsafe-swiftshader", "--use-gl=swiftshader"],
},
}),
headless: true,
instances: [{ browser: "chromium" }],
},
testTimeout: 120_000,
hookTimeout: 30_000,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
assetsInclude: ["**/*.webm"],
});