Merge branch 'main' of github.com:siddharthvaddem/openscreen into feature/color-wheel
This commit is contained in:
@@ -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
@@ -29,4 +29,7 @@ release/**
|
||||
|
||||
# Playwright
|
||||
test-results
|
||||
playwright-report/
|
||||
playwright-report/
|
||||
|
||||
# Vitest browser mode screenshots
|
||||
__screenshots__/
|
||||
Vendored
+2
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
Generated
+1679
-201
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -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"
|
||||
},
|
||||
@@ -74,7 +76,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",
|
||||
@@ -83,6 +85,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",
|
||||
|
||||
@@ -11,7 +11,7 @@ import path from "node:path";
|
||||
|
||||
const LOCALES_DIR = path.resolve("src/i18n/locales");
|
||||
const BASE_LOCALE = "en";
|
||||
const COMPARE_LOCALES = ["zh-CN", "es"];
|
||||
const COMPARE_LOCALES = ["zh-CN", "es", "tr"];
|
||||
|
||||
function getKeys(obj, prefix = "") {
|
||||
const keys = [];
|
||||
|
||||
@@ -52,10 +52,74 @@ import type {
|
||||
PlaybackSpeed,
|
||||
WebcamLayoutPreset,
|
||||
WebcamMaskShape,
|
||||
WebcamSizePreset,
|
||||
ZoomDepth,
|
||||
ZoomFocusMode,
|
||||
} from "./types";
|
||||
import { SPEED_OPTIONS } from "./types";
|
||||
import { DEFAULT_WEBCAM_SIZE_PRESET, 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(
|
||||
@@ -132,7 +196,11 @@ interface SettingsPanelProps {
|
||||
onGifSizePresetChange?: (preset: GifSizePreset) => void;
|
||||
gifOutputDimensions?: { width: number; height: number };
|
||||
onExport?: () => void;
|
||||
unsavedExport?: { arrayBuffer: ArrayBuffer; fileName: string; format: string } | null;
|
||||
unsavedExport?: {
|
||||
arrayBuffer: ArrayBuffer;
|
||||
fileName: string;
|
||||
format: string;
|
||||
} | null;
|
||||
onSaveUnsavedExport?: () => void;
|
||||
selectedAnnotationId?: string | null;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
@@ -150,6 +218,9 @@ interface SettingsPanelProps {
|
||||
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
|
||||
webcamMaskShape?: import("./types").WebcamMaskShape;
|
||||
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
|
||||
onWebcamSizePresetCommit?: () => void;
|
||||
}
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -223,6 +294,9 @@ export function SettingsPanel({
|
||||
onWebcamLayoutPresetChange,
|
||||
webcamMaskShape = "rectangle",
|
||||
onWebcamMaskShapeChange,
|
||||
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
onWebcamSizePresetChange,
|
||||
onWebcamSizePresetCommit,
|
||||
}: SettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
|
||||
@@ -584,7 +658,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 +683,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>
|
||||
)}
|
||||
@@ -751,6 +848,27 @@ export function SettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{webcamLayoutPreset === "picture-in-picture" && (
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5 mt-2">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("layout.webcamSize")}
|
||||
</div>
|
||||
<div className="text-[10px] font-medium text-slate-400">
|
||||
{webcamSizePreset}%
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
value={[webcamSizePreset]}
|
||||
onValueChange={(values) => onWebcamSizePresetChange?.(values[0])}
|
||||
onValueCommit={() => onWebcamSizePresetCommit?.()}
|
||||
min={10}
|
||||
max={50}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
@@ -1014,7 +1132,9 @@ export function SettingsPanel({
|
||||
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5",
|
||||
)}
|
||||
style={{ background: g }}
|
||||
aria-label={t("background.gradientLabel", { index: idx + 1 })}
|
||||
aria-label={t("background.gradientLabel", {
|
||||
index: idx + 1,
|
||||
})}
|
||||
onClick={() => {
|
||||
setGradient(g);
|
||||
onWallpaperChange(g);
|
||||
|
||||
@@ -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";
|
||||
@@ -90,6 +98,7 @@ export default function VideoEditor() {
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
} = editorState;
|
||||
|
||||
@@ -117,6 +126,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);
|
||||
@@ -206,6 +216,7 @@ export default function VideoEditor() {
|
||||
aspectRatio: normalizedEditor.aspectRatio,
|
||||
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
|
||||
webcamMaskShape: normalizedEditor.webcamMaskShape,
|
||||
webcamSizePreset: normalizedEditor.webcamSizePreset,
|
||||
webcamPosition: normalizedEditor.webcamPosition,
|
||||
});
|
||||
setExportQuality(normalizedEditor.exportQuality);
|
||||
@@ -296,6 +307,7 @@ export default function VideoEditor() {
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
@@ -416,6 +428,7 @@ export default function VideoEditor() {
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
@@ -471,6 +484,7 @@ export default function VideoEditor() {
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
@@ -501,6 +515,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();
|
||||
|
||||
@@ -678,7 +702,11 @@ export default function VideoEditor() {
|
||||
pushState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
|
||||
? {
|
||||
...region,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
@@ -691,7 +719,11 @@ export default function VideoEditor() {
|
||||
pushState((prev) => ({
|
||||
trimRegions: prev.trimRegions.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
|
||||
? {
|
||||
...region,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
@@ -717,7 +749,11 @@ export default function VideoEditor() {
|
||||
pushState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.map((region) =>
|
||||
region.id === selectedZoomId
|
||||
? { ...region, depth, focus: clampFocusToDepth(region.focus, depth) }
|
||||
? {
|
||||
...region,
|
||||
depth,
|
||||
focus: clampFocusToDepth(region.focus, depth),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
@@ -739,7 +775,9 @@ export default function VideoEditor() {
|
||||
|
||||
const handleZoomDelete = useCallback(
|
||||
(id: string) => {
|
||||
pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) }));
|
||||
pushState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.filter((r) => r.id !== id),
|
||||
}));
|
||||
if (selectedZoomId === id) {
|
||||
setSelectedZoomId(null);
|
||||
}
|
||||
@@ -749,7 +787,9 @@ export default function VideoEditor() {
|
||||
|
||||
const handleTrimDelete = useCallback(
|
||||
(id: string) => {
|
||||
pushState((prev) => ({ trimRegions: prev.trimRegions.filter((r) => r.id !== id) }));
|
||||
pushState((prev) => ({
|
||||
trimRegions: prev.trimRegions.filter((r) => r.id !== id),
|
||||
}));
|
||||
if (selectedTrimId === id) {
|
||||
setSelectedTrimId(null);
|
||||
}
|
||||
@@ -775,7 +815,9 @@ export default function VideoEditor() {
|
||||
endMs: Math.round(span.end),
|
||||
speed: DEFAULT_PLAYBACK_SPEED,
|
||||
};
|
||||
pushState((prev) => ({ speedRegions: [...prev.speedRegions, newRegion] }));
|
||||
pushState((prev) => ({
|
||||
speedRegions: [...prev.speedRegions, newRegion],
|
||||
}));
|
||||
setSelectedSpeedId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
@@ -840,7 +882,9 @@ export default function VideoEditor() {
|
||||
style: { ...DEFAULT_ANNOTATION_STYLE },
|
||||
zIndex,
|
||||
};
|
||||
pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, newRegion] }));
|
||||
pushState((prev) => ({
|
||||
annotationRegions: [...prev.annotationRegions, newRegion],
|
||||
}));
|
||||
setSelectedAnnotationId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
@@ -853,7 +897,11 @@ export default function VideoEditor() {
|
||||
pushState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
|
||||
? {
|
||||
...region,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
@@ -1174,6 +1222,7 @@ export default function VideoEditor() {
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
@@ -1307,6 +1356,7 @@ export default function VideoEditor() {
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
@@ -1377,6 +1427,7 @@ export default function VideoEditor() {
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
handleExportSaved,
|
||||
@@ -1482,6 +1533,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 +1586,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}
|
||||
@@ -1563,6 +1650,7 @@ export default function VideoEditor() {
|
||||
webcamVideoPath={webcamVideoPath || undefined}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
webcamPosition={webcamPosition}
|
||||
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
|
||||
onWebcamPositionDragEnd={commitState}
|
||||
@@ -1713,6 +1801,9 @@ export default function VideoEditor() {
|
||||
}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })}
|
||||
onWebcamSizePresetCommit={commitState}
|
||||
videoElement={videoPlaybackRef.current?.video || null}
|
||||
exportQuality={exportQuality}
|
||||
onExportQualityChange={setExportQuality}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type Size,
|
||||
type StyledRenderRect,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamSizePreset,
|
||||
} from "@/lib/compositeLayout";
|
||||
import { getCssClipPath } from "@/lib/webcamMaskShapes";
|
||||
import {
|
||||
@@ -69,6 +70,7 @@ interface VideoPlaybackProps {
|
||||
webcamVideoPath?: string;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
webcamMaskShape?: import("./types").WebcamMaskShape;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
|
||||
onWebcamPositionDragEnd?: () => void;
|
||||
@@ -119,6 +121,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
webcamVideoPath,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
onWebcamPositionChange,
|
||||
onWebcamPositionDragEnd,
|
||||
@@ -195,7 +198,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const isPlayingRef = useRef(isPlaying);
|
||||
const isSeekingRef = useRef(false);
|
||||
const allowPlaybackRef = useRef(false);
|
||||
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
|
||||
const lockedVideoDimensionsRef = useRef<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const layoutVideoContentRef = useRef<(() => void) | null>(null);
|
||||
const trimRegionsRef = useRef<TrimRegion[]>([]);
|
||||
const speedRegionsRef = useRef<SpeedRegion[]>([]);
|
||||
@@ -283,6 +289,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
padding,
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
});
|
||||
@@ -314,6 +321,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
padding,
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
]);
|
||||
@@ -648,7 +656,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
app.ticker.maxFPS = 60;
|
||||
|
||||
if (!mounted) {
|
||||
app.destroy(true, { children: true, texture: true, textureSource: true });
|
||||
app.destroy(true, {
|
||||
children: true,
|
||||
texture: true,
|
||||
textureSource: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -672,7 +684,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
mounted = false;
|
||||
setPixiReady(false);
|
||||
if (app && app.renderer) {
|
||||
app.destroy(true, { children: true, texture: true, textureSource: true });
|
||||
app.destroy(true, {
|
||||
children: true,
|
||||
texture: true,
|
||||
textureSource: true,
|
||||
});
|
||||
}
|
||||
appRef.current = null;
|
||||
cameraContainerRef.current = null;
|
||||
@@ -853,12 +869,19 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const ss = stageSizeRef.current;
|
||||
const viewportRatio =
|
||||
bm.width > 0 && bm.height > 0
|
||||
? { widthRatio: ss.width / bm.width, heightRatio: ss.height / bm.height }
|
||||
? {
|
||||
widthRatio: ss.width / bm.width,
|
||||
heightRatio: ss.height / bm.height,
|
||||
}
|
||||
: undefined;
|
||||
const { region, strength, blendedScale, transition } = findDominantRegion(
|
||||
zoomRegionsRef.current,
|
||||
currentTimeRef.current,
|
||||
{ connectZooms: true, cursorTelemetry: cursorTelemetryRef.current, viewportRatio },
|
||||
{
|
||||
connectZooms: true,
|
||||
cursorTelemetry: cursorTelemetryRef.current,
|
||||
viewportRatio,
|
||||
},
|
||||
);
|
||||
|
||||
const defaultFocus = DEFAULT_FOCUS;
|
||||
|
||||
@@ -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,
|
||||
@@ -14,12 +15,16 @@ import {
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
MIN_PLAYBACK_SPEED,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamMaskShape,
|
||||
type WebcamPosition,
|
||||
type WebcamSizePreset,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
|
||||
@@ -47,6 +52,7 @@ export interface ProjectEditorState {
|
||||
aspectRatio: AspectRatio;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
webcamMaskShape: WebcamMaskShape;
|
||||
webcamSizePreset: WebcamSizePreset;
|
||||
webcamPosition: WebcamPosition | null;
|
||||
exportQuality: ExportQuality;
|
||||
exportFormat: ExportFormat;
|
||||
@@ -223,14 +229,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 {
|
||||
@@ -363,6 +365,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
editor.webcamMaskShape === "rounded"
|
||||
? editor.webcamMaskShape
|
||||
: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
webcamSizePreset:
|
||||
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
|
||||
? Math.max(10, Math.min(50, editor.webcamSizePreset))
|
||||
: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
webcamPosition:
|
||||
editor.webcamPosition &&
|
||||
typeof editor.webcamPosition === "object" &&
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
|
||||
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
export type ZoomFocusMode = "manual" | "auto";
|
||||
export type { WebcamLayoutPreset };
|
||||
/** Webcam size as a percentage of the canvas reference dimension (10–50). */
|
||||
export type WebcamSizePreset = number;
|
||||
|
||||
export const DEFAULT_WEBCAM_SIZE_PRESET: WebcamSizePreset = 25;
|
||||
|
||||
export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
|
||||
|
||||
@@ -138,7 +142,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 +168,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;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type Size,
|
||||
type StyledRenderRect,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamSizePreset,
|
||||
} from "@/lib/compositeLayout";
|
||||
import type { CropRegion, WebcamMaskShape } from "../types";
|
||||
|
||||
@@ -20,6 +21,7 @@ interface LayoutParams {
|
||||
padding?: number;
|
||||
webcamDimensions?: Size | null;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
webcamMaskShape?: WebcamMaskShape;
|
||||
}
|
||||
@@ -47,6 +49,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
padding = 0,
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
} = params;
|
||||
@@ -95,6 +98,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||
webcamSize: webcamDimensions,
|
||||
layoutPreset: webcamLayoutPreset,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
WebcamLayoutPreset,
|
||||
WebcamMaskShape,
|
||||
WebcamPosition,
|
||||
WebcamSizePreset,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
} from "@/components/video-editor/types";
|
||||
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
|
||||
@@ -34,6 +36,7 @@ export interface EditorState {
|
||||
aspectRatio: AspectRatio;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
webcamMaskShape: WebcamMaskShape;
|
||||
webcamSizePreset: WebcamSizePreset;
|
||||
webcamPosition: WebcamPosition | null;
|
||||
}
|
||||
|
||||
@@ -52,6 +55,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
|
||||
aspectRatio: "16:9",
|
||||
webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
webcamSizePreset: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
webcamPosition: DEFAULT_WEBCAM_POSITION,
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
export const DEFAULT_LOCALE = "en" as const;
|
||||
export const SUPPORTED_LOCALES = ["en", "zh-CN", "es"] as const;
|
||||
export const SUPPORTED_LOCALES = ["en", "zh-CN", "es", , "fr", "tr"] as const;
|
||||
export const I18N_NAMESPACES = [
|
||||
"common",
|
||||
"dialogs",
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -24,7 +26,8 @@
|
||||
"selectPreset": "Select preset",
|
||||
"pictureInPicture": "Picture in Picture",
|
||||
"verticalStack": "Vertical Stack",
|
||||
"webcamShape": "Camera Shape"
|
||||
"webcamShape": "Camera Shape",
|
||||
"webcamSize": "Webcam Size"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Video Effects",
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
@@ -24,7 +26,8 @@
|
||||
"selectPreset": "Seleccionar predefinido",
|
||||
"pictureInPicture": "Imagen en imagen",
|
||||
"verticalStack": "Apilado vertical",
|
||||
"webcamShape": "Forma de cámara"
|
||||
"webcamShape": "Forma de cámara",
|
||||
"webcamSize": "Tamaño de cámara"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Efectos de video",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"actions": {
|
||||
"cancel": "İptal",
|
||||
"save": "Kaydet",
|
||||
"delete": "Sil",
|
||||
"close": "Kapat",
|
||||
"share": "Paylaş",
|
||||
"done": "Tamam",
|
||||
"open": "Aç",
|
||||
"upload": "Yükle",
|
||||
"export": "Dışa Aktar",
|
||||
"file": "Dosya",
|
||||
"edit": "Düzenle",
|
||||
"view": "Görünüm",
|
||||
"window": "Pencere",
|
||||
"quit": "Çıkış",
|
||||
"stopRecording": "Kaydı Durdur"
|
||||
},
|
||||
"playback": {
|
||||
"play": "Oynat",
|
||||
"pause": "Duraklat",
|
||||
"fullscreen": "Tam Ekran",
|
||||
"exitFullscreen": "Tam Ekrandan Çık"
|
||||
},
|
||||
"locale": {
|
||||
"name": "Türkçe",
|
||||
"short": "TR"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"export": {
|
||||
"complete": "Dışa Aktarım Tamamlandı",
|
||||
"yourFormatReady": "{{format}} dosyanız hazır",
|
||||
"showInFolder": "Klasörde Göster",
|
||||
"finalizingVideo": "Video dışa aktarımı sonlandırılıyor...",
|
||||
"compilingGifProgress": "GIF derleniyor... %{{progress}}",
|
||||
"compilingGifWait": "GIF derleniyor... Bu biraz zaman alabilir",
|
||||
"takeMoment": "Bu biraz zaman alabilir...",
|
||||
"failed": "Dışa Aktarım Başarısız",
|
||||
"tryAgain": "Lütfen tekrar deneyin",
|
||||
"finalizingVideoTitle": "Video Sonlandırılıyor",
|
||||
"compilingGif": "GIF Derleniyor",
|
||||
"exportingFormat": "{{format}} Dışa Aktarılıyor",
|
||||
"compiling": "Derleniyor",
|
||||
"renderingFrames": "Kareler İşleniyor",
|
||||
"processing": "İşleniyor...",
|
||||
"finalizing": "Sonlandırılıyor...",
|
||||
"compilingStatus": "Derleniyor...",
|
||||
"status": "Durum",
|
||||
"format": "Biçim",
|
||||
"frames": "Kareler",
|
||||
"cancelExport": "Dışa Aktarımı İptal Et",
|
||||
"savedSuccessfully": "{{format}} başarıyla kaydedildi!"
|
||||
},
|
||||
"tutorial": {
|
||||
"triggerLabel": "Kırpma nasıl çalışır",
|
||||
"title": "Kırpma Nasıl Çalışır",
|
||||
"description": "Videonuzun istenmeyen bölümlerini nasıl keseceğinizi anlayın.",
|
||||
"explanation": "Kırpma aracı, kaldırmak istediğiniz bölümleri tanımlayarak çalışır.",
|
||||
"explanationRemove": "kaldırmak",
|
||||
"explanationCovered": "kaplanan",
|
||||
"explanationEnd": "kırmızı kırpma bölgesi ile işaretlenen kısımlar dışa aktarımda kesilecektir.",
|
||||
"visualExample": "Görsel Örnek",
|
||||
"removed": "KALDIRILDI",
|
||||
"kept": "Korundu",
|
||||
"part1": "Bölüm 1",
|
||||
"part2": "Bölüm 2",
|
||||
"part3": "Bölüm 3",
|
||||
"finalVideo": "Son Video",
|
||||
"step1Title": "1. Kırpma Ekle",
|
||||
"step1Description": "Kaldırılacak bölümü işaretlemek için T tuşuna basın veya makas simgesine tıklayın.",
|
||||
"step2Title": "2. Ayarla",
|
||||
"step2Description": "Kesmek istediğiniz kısmı tam olarak kaplamak için kırmızı bölgenin kenarlarını sürükleyin."
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "Kaydedilmemiş Değişiklikler",
|
||||
"message": "Kaydedilmemiş değişiklikleriniz var.",
|
||||
"detail": "Kapatmadan önce projenizi kaydetmek ister misiniz?",
|
||||
"saveAndClose": "Kaydet ve Kapat",
|
||||
"discardAndClose": "Kaydetmeden Kapat",
|
||||
"loadProject": "Proje Yükle…",
|
||||
"saveProject": "Proje Kaydet…",
|
||||
"saveProjectAs": "Farklı Kaydet…"
|
||||
},
|
||||
"fileDialogs": {
|
||||
"saveGif": "Dışa Aktarılan GIF'i Kaydet",
|
||||
"saveVideo": "Dışa Aktarılan Videoyu Kaydet",
|
||||
"selectVideo": "Video Dosyası Seç",
|
||||
"saveProject": "OpenScreen Projesini Kaydet",
|
||||
"openProject": "OpenScreen Projesini Aç",
|
||||
"gifImage": "GIF Görüntüsü",
|
||||
"mp4Video": "MP4 Video",
|
||||
"videoFiles": "Video Dosyaları",
|
||||
"openscreenProject": "OpenScreen Projesi",
|
||||
"allFiles": "Tüm Dosyalar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"errors": {
|
||||
"noVideoLoaded": "Video yüklenmedi",
|
||||
"videoNotReady": "Video hazır değil",
|
||||
"unableToDetermineSourcePath": "Kaynak video yolu belirlenemiyor",
|
||||
"failedToSaveGif": "GIF kaydedilemedi",
|
||||
"gifExportFailed": "GIF dışa aktarımı başarısız oldu",
|
||||
"failedToSaveVideo": "Video kaydedilemedi",
|
||||
"exportFailed": "Dışa aktarım başarısız oldu",
|
||||
"exportFailedWithError": "Dışa aktarım başarısız: {{error}}",
|
||||
"failedToSaveExport": "Dışa aktarım kaydedilemedi",
|
||||
"failedToSaveExportedVideo": "Dışa aktarılan video kaydedilemedi",
|
||||
"failedToRevealInFolder": "Klasörde gösterme hatası: {{error}}"
|
||||
},
|
||||
"export": {
|
||||
"canceled": "Dışa aktarım iptal edildi",
|
||||
"exportedSuccessfully": "{{format}} başarıyla dışa aktarıldı"
|
||||
},
|
||||
"project": {
|
||||
"saveCanceled": "Proje kaydetme iptal edildi",
|
||||
"failedToSave": "Proje kaydedilemedi",
|
||||
"savedTo": "Proje şuraya kaydedildi: {{path}}",
|
||||
"failedToLoad": "Proje yüklenemedi",
|
||||
"invalidFormat": "Geçersiz proje dosyası biçimi",
|
||||
"loadedFrom": "Proje şuradan yüklendi: {{path}}"
|
||||
},
|
||||
"recording": {
|
||||
"failedCameraAccess": "Kamera erişimi istenemedi.",
|
||||
"cameraBlocked": "Kamera erişimi engellendi. Kamerayı kullanmak için sistem ayarlarından izin verin.",
|
||||
"systemAudioUnavailable": "Sistem sesi kullanılamıyor. Sistem sesi olmadan kaydediliyor.",
|
||||
"microphoneDenied": "Mikrofon erişimi reddedildi. Kayıt ses olmadan devam edecek.",
|
||||
"cameraDenied": "Kamera erişimi reddedildi. Kayıt kamera olmadan devam edecek.",
|
||||
"permissionDenied": "Kayıt izni reddedildi. Lütfen ekran kaydına izin verin."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"tooltips": {
|
||||
"hideHUD": "Kontrol panelini gizle",
|
||||
"closeApp": "Uygulamayı kapat",
|
||||
"restartRecording": "Kaydı yeniden başlat",
|
||||
"cancelRecording": "Kaydı iptal et",
|
||||
"pauseRecording": "Kaydı duraklat",
|
||||
"resumeRecording": "Kayda devam et",
|
||||
"openVideoFile": "Video dosyası aç",
|
||||
"openProject": "Proje aç"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "Sistem sesini etkinleştir",
|
||||
"disableSystemAudio": "Sistem sesini devre dışı bırak",
|
||||
"enableMicrophone": "Mikrofonu etkinleştir",
|
||||
"disableMicrophone": "Mikrofonu devre dışı bırak",
|
||||
"defaultMicrophone": "Varsayılan Mikrofon",
|
||||
"enableNoiseReduction": "Gürültü azaltmayı etkinleştir (yapay zeka destekli)",
|
||||
"disableNoiseReduction": "Gürültü azaltmayı devre dışı bırak",
|
||||
"noiseReduction": "Gürültü azaltma",
|
||||
"clickToCycle": "Seviye değiştirmek için tıklayın",
|
||||
"nrLevel": {
|
||||
"light": "Hafif",
|
||||
"moderate": "Orta",
|
||||
"aggressive": "Güçlü"
|
||||
},
|
||||
"noiseReductionPrompt": "Daha net ses için yapay zeka destekli gürültü azaltmayı etkinleştirmek ister misiniz?",
|
||||
"enableNoiseReductionShort": "Etkinleştir"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "Kamerayı etkinleştir",
|
||||
"disableWebcam": "Kamerayı devre dışı bırak",
|
||||
"defaultCamera": "Varsayılan Kamera",
|
||||
"searching": "Aranıyor...",
|
||||
"noneFound": "Kamera bulunamadı",
|
||||
"unavailable": "Kamera kullanılamıyor"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "Kaynaklar yükleniyor...",
|
||||
"screens": "Ekranlar ({{count}})",
|
||||
"windows": "Pencereler ({{count}})",
|
||||
"defaultSourceName": "Ekran"
|
||||
},
|
||||
"recording": {
|
||||
"selectSource": "Lütfen kayıt için bir kaynak seçin"
|
||||
},
|
||||
"language": "Dil"
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"zoom": {
|
||||
"level": "Yakınlaştırma Seviyesi",
|
||||
"selectRegion": "Ayarlamak için bir yakınlaştırma bölgesi seçin",
|
||||
"deleteZoom": "Yakınlaştırmayı Sil",
|
||||
"focusMode": {
|
||||
"title": "Odak Modu",
|
||||
"manual": "Manuel",
|
||||
"auto": "Otomatik",
|
||||
"autoDescription": "Kamera kaydedilen imleç konumunu takip eder"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "Oynatma Hızı",
|
||||
"selectRegion": "Ayarlamak için bir hız bölgesi seçin",
|
||||
"deleteRegion": "Hız Bölgesini Sil"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "Kırpma Bölgesini Sil"
|
||||
},
|
||||
"layout": {
|
||||
"title": "Düzen",
|
||||
"preset": "Ön Ayar",
|
||||
"selectPreset": "Ön ayar seçin",
|
||||
"pictureInPicture": "Resim İçinde Resim",
|
||||
"verticalStack": "Dikey Yığın",
|
||||
"webcamShape": "Kamera Şekli"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Video Efektleri",
|
||||
"blurBg": "Arka Planı Bulanıklaştır",
|
||||
"motionBlur": "Hareket Bulanıklığı",
|
||||
"off": "kapalı",
|
||||
"shadow": "Gölge",
|
||||
"roundness": "Yuvarlaklık",
|
||||
"padding": "Dolgu"
|
||||
},
|
||||
"background": {
|
||||
"title": "Arka Plan",
|
||||
"image": "Görüntü",
|
||||
"color": "Renk",
|
||||
"gradient": "Gradyan",
|
||||
"uploadCustom": "Özel Yükle",
|
||||
"gradientLabel": "Gradyan {{index}}"
|
||||
},
|
||||
"crop": {
|
||||
"title": "Kırpma",
|
||||
"cropVideo": "Videoyu Kırp",
|
||||
"dragInstruction": "Kırpma alanını ayarlamak için her kenarı sürükleyin",
|
||||
"ratio": "Oran",
|
||||
"free": "Serbest",
|
||||
"done": "Tamam",
|
||||
"lockAspectRatio": "En boy oranını kilitle",
|
||||
"unlockAspectRatio": "En boy oranının kilidini aç"
|
||||
},
|
||||
"exportFormat": {
|
||||
"mp4": "MP4",
|
||||
"gif": "GIF",
|
||||
"mp4Video": "MP4 Video",
|
||||
"mp4Description": "Yüksek kaliteli video dosyası",
|
||||
"gifAnimation": "GIF Animasyon",
|
||||
"gifDescription": "Paylaşım için hareketli görüntü"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "Dışa Aktarım Kalitesi",
|
||||
"low": "Düşük",
|
||||
"medium": "Orta",
|
||||
"high": "Yüksek"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "GIF Kare Hızı",
|
||||
"size": "GIF Boyutu",
|
||||
"loop": "GIF Döngüsü"
|
||||
},
|
||||
"project": {
|
||||
"save": "Projeyi Kaydet",
|
||||
"load": "Proje Yükle"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "Videoyu Dışa Aktar",
|
||||
"gifButton": "GIF Olarak Dışa Aktar",
|
||||
"chooseSaveLocation": "Kayıt Konumu Seç"
|
||||
},
|
||||
"links": {
|
||||
"reportBug": "Hata Bildir",
|
||||
"starOnGithub": "GitHub'da Yıldızla"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "Geçersiz dosya türü",
|
||||
"jpgOnly": "Lütfen bir JPG veya JPEG görüntü dosyası yükleyin.",
|
||||
"uploadSuccess": "Özel görüntü başarıyla yüklendi!",
|
||||
"failedToUpload": "Görüntü yüklenemedi",
|
||||
"errorReading": "Dosya okunurken bir hata oluştu."
|
||||
},
|
||||
"annotation": {
|
||||
"title": "Açıklama Ayarları",
|
||||
"active": "Aktif",
|
||||
"typeText": "Metin",
|
||||
"typeImage": "Görüntü",
|
||||
"typeArrow": "Ok",
|
||||
"textContent": "Metin İçeriği",
|
||||
"textPlaceholder": "Metninizi girin...",
|
||||
"fontStyle": "Yazı Tipi Stili",
|
||||
"selectStyle": "Stil seçin",
|
||||
"size": "Boyut",
|
||||
"customFonts": "Özel Yazı Tipleri",
|
||||
"textColor": "Metin Rengi",
|
||||
"background": "Arka Plan",
|
||||
"none": "Yok",
|
||||
"color": "Renk",
|
||||
"clearBackground": "Arka Planı Temizle",
|
||||
"uploadImage": "Görüntü Yükle",
|
||||
"supportedFormats": "Desteklenen biçimler: JPG, PNG, GIF, WebP",
|
||||
"arrowDirection": "Ok Yönü",
|
||||
"strokeWidth": "Çizgi Kalınlığı: {{width}}px",
|
||||
"arrowColor": "Ok Rengi",
|
||||
"deleteAnnotation": "Açıklamayı Sil",
|
||||
"shortcutsAndTips": "Kısayollar ve İpuçları",
|
||||
"tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
|
||||
"tipTabCycle": "Çakışan öğeler arasında geçiş yapmak için Tab tuşunu kullanın.",
|
||||
"tipShiftTabCycle": "Geriye doğru geçiş yapmak için Shift+Tab kullanın.",
|
||||
"invalidImageType": "Geçersiz dosya türü",
|
||||
"imageFormatsOnly": "Lütfen bir JPG, PNG, GIF veya WebP görüntü dosyası yükleyin.",
|
||||
"imageUploadSuccess": "Görüntü başarıyla yüklendi!",
|
||||
"failedImageUpload": "Görüntü yüklenemedi"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "Klasik",
|
||||
"editor": "Editör",
|
||||
"strong": "Kalın",
|
||||
"typewriter": "Daktilo",
|
||||
"deco": "Dekoratif",
|
||||
"simple": "Sade",
|
||||
"modern": "Modern",
|
||||
"clean": "Temiz"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Google Yazı Tipi Ekle",
|
||||
"urlLabel": "Google Fonts İçe Aktarım URL'si",
|
||||
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||
"urlHelp": "Google Fonts'tan alabilirsiniz: Bir yazı tipi seçin → \"Get font\"a tıklayın → @import URL'sini kopyalayın",
|
||||
"nameLabel": "Görünen Ad",
|
||||
"namePlaceholder": "Özel Yazı Tipim",
|
||||
"nameHelp": "Yazı tipinin seçicide nasıl görüneceğini belirler",
|
||||
"addButton": "Yazı Tipi Ekle",
|
||||
"addingButton": "Ekleniyor...",
|
||||
"errorEmptyUrl": "Lütfen bir Google Fonts içe aktarım URL'si girin",
|
||||
"errorInvalidUrl": "Lütfen geçerli bir Google Fonts URL'si girin",
|
||||
"errorEmptyName": "Lütfen bir yazı tipi adı girin",
|
||||
"errorExtractFailed": "URL'den yazı tipi ailesi çıkarılamadı",
|
||||
"successMessage": "\"{{fontName}}\" yazı tipi başarıyla eklendi",
|
||||
"failedToAdd": "Yazı tipi eklenemedi",
|
||||
"errorTimeout": "Yazı tipinin yüklenmesi çok uzun sürdü. Lütfen URL'yi kontrol edip tekrar deneyin.",
|
||||
"errorLoadFailed": "Yazı tipi yüklenemedi. Lütfen Google Fonts URL'sinin doğruluğunu kontrol edin."
|
||||
},
|
||||
"language": {
|
||||
"title": "Dil"
|
||||
},
|
||||
"audio": {
|
||||
"title": "Ses",
|
||||
"noiseReduction": "Gürültü Azaltma",
|
||||
"level": "Seviye",
|
||||
"nrLevel": {
|
||||
"light": "Hafif",
|
||||
"moderate": "Orta",
|
||||
"aggressive": "Güçlü"
|
||||
},
|
||||
"nrDescription": "Yapay zeka destekli gürültü azaltma arka plan gürültüsünü temizler. Daha yüksek seviyeler daha agresiftir ancak ses kalitesini etkileyebilir."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"title": "Klavye Kısayolları",
|
||||
"customize": "Özelleştir",
|
||||
"configurable": "Yapılandırılabilir",
|
||||
"fixed": "Sabit",
|
||||
"pressKey": "Bir tuşa basın…",
|
||||
"clickToChange": "Değiştirmek için tıklayın",
|
||||
"pressEscToCancel": "İptal etmek için Esc tuşuna basın",
|
||||
"helpText": "Bir kısayola tıklayın, ardından yeni tuş kombinasyonuna basın. İptal etmek için Esc tuşuna basın.",
|
||||
"resetToDefaults": "Varsayılanlara sıfırla",
|
||||
"alreadyUsedBy": "\"{{action}}\" tarafından zaten kullanılıyor",
|
||||
"swap": "Değiştir",
|
||||
"reservedShortcut": "Bu kısayol \"{{label}}\" için ayrılmıştır ve yeniden atanamaz.",
|
||||
"savedToast": "Klavye kısayolları kaydedildi",
|
||||
"resetToast": "Varsayılan kısayollara sıfırlandı — uygulamak için Kaydet'e tıklayın",
|
||||
"actions": {
|
||||
"addZoom": "Yakınlaştırma Ekle",
|
||||
"addTrim": "Kırpma Ekle",
|
||||
"addSpeed": "Hız Ekle",
|
||||
"addAnnotation": "Açıklama Ekle",
|
||||
"addKeyframe": "Anahtar Kare Ekle",
|
||||
"deleteSelected": "Seçileni Sil",
|
||||
"playPause": "Oynat / Duraklat"
|
||||
},
|
||||
"fixedActions": {
|
||||
"undo": "Geri Al",
|
||||
"redo": "Yinele",
|
||||
"cycleAnnotationsForward": "Açıklamalar Arasında İleri Geç",
|
||||
"cycleAnnotationsBackward": "Açıklamalar Arasında Geri Geç",
|
||||
"deleteSelectedAlt": "Seçileni Sil (alternatif)",
|
||||
"panTimeline": "Zaman Çizelgesini Kaydır",
|
||||
"zoomTimeline": "Zaman Çizelgesini Yakınlaştır",
|
||||
"frameBack": "Önceki Kare",
|
||||
"frameForward": "Sonraki Kare"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"buttons": {
|
||||
"addZoom": "Yakınlaştırma Ekle (Z)",
|
||||
"suggestZooms": "İmleçten Yakınlaştırma Öner",
|
||||
"addTrim": "Kırpma Ekle (T)",
|
||||
"addAnnotation": "Açıklama Ekle (A)",
|
||||
"addSpeed": "Hız Ekle (S)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Yakınlaştırma eklemek için Z tuşuna basın",
|
||||
"pressTrim": "Kırpma eklemek için T tuşuna basın",
|
||||
"pressAnnotation": "Açıklama eklemek için A tuşuna basın",
|
||||
"pressSpeed": "Hız eklemek için S tuşuna basın"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Kaydır",
|
||||
"zoom": "Yakınlaştır",
|
||||
"zoomItem": "Yakınlaştırma {{index}}",
|
||||
"trimItem": "Kırpma {{index}}",
|
||||
"speedItem": "Hız {{index}}",
|
||||
"annotationItem": "Açıklama",
|
||||
"imageItem": "Görüntü",
|
||||
"emptyText": "Boş metin"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "Video Yüklenmedi",
|
||||
"dragAndDrop": "Düzenlemeye başlamak için bir video sürükleyip bırakın"
|
||||
},
|
||||
"errors": {
|
||||
"cannotPlaceZoom": "Buraya yakınlaştırma yerleştirilemiyor",
|
||||
"zoomExistsAtLocation": "Bu konumda zaten bir yakınlaştırma var veya yeterli alan yok.",
|
||||
"zoomSuggestionUnavailable": "Yakınlaştırma öneri işleyicisi kullanılamıyor",
|
||||
"noCursorTelemetry": "İmleç telemetrisi mevcut değil",
|
||||
"noCursorTelemetryDescription": "İmleç tabanlı öneriler oluşturmak için önce bir ekran kaydı yapın.",
|
||||
"noUsableTelemetry": "Kullanılabilir imleç telemetrisi yok",
|
||||
"noUsableTelemetryDescription": "Kayıt yeterli imleç hareketi verisi içermiyor.",
|
||||
"noDwellMoments": "Belirgin imleç bekleme anları bulunamadı",
|
||||
"noDwellMomentsDescription": "Önemli işlemlerde daha yavaş imleç duraklamaları olan bir kayıt deneyin.",
|
||||
"noAutoZoomSlots": "Otomatik yakınlaştırma alanı yok",
|
||||
"noAutoZoomSlotsDescription": "Algılanan bekleme noktaları mevcut yakınlaştırma bölgeleriyle çakışıyor.",
|
||||
"cannotPlaceTrim": "Buraya kırpma yerleştirilemiyor",
|
||||
"trimExistsAtLocation": "Bu konumda zaten bir kırpma var veya yeterli alan yok.",
|
||||
"cannotPlaceSpeed": "Buraya hız yerleştirilemiyor",
|
||||
"speedExistsAtLocation": "Bu konumda zaten bir hız bölgesi var veya yeterli alan yok."
|
||||
},
|
||||
"success": {
|
||||
"addedZoomSuggestions": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi",
|
||||
"addedZoomSuggestionsPlural": "{{count}} imleç tabanlı yakınlaştırma önerisi eklendi"
|
||||
}
|
||||
}
|
||||
@@ -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": "拖动红色区域的边缘,精确覆盖您要剪掉的部分。"
|
||||
},
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"speed": {
|
||||
"playbackSpeed": "播放速度",
|
||||
"selectRegion": "选择要调整的速度区域",
|
||||
"deleteRegion": "删除速度区域"
|
||||
"deleteRegion": "删除速度区域",
|
||||
"customPlaybackSpeed": "自定义播放速度",
|
||||
"maxSpeedError": "速度不能超过 16×"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "删除剪辑区域"
|
||||
@@ -24,7 +26,8 @@
|
||||
"selectPreset": "选择预设",
|
||||
"pictureInPicture": "画中画",
|
||||
"verticalStack": "垂直堆叠",
|
||||
"webcamShape": "摄像头形状"
|
||||
"webcamShape": "摄像头形状",
|
||||
"webcamSize": "摄像头大小"
|
||||
},
|
||||
"effects": {
|
||||
"title": "视频效果",
|
||||
|
||||
+109
-18
@@ -24,16 +24,111 @@ describe("computeCompositeLayout", () => {
|
||||
webcamSize: { width: 1920, height: 1080 },
|
||||
});
|
||||
|
||||
const refDim = Math.sqrt(1280 * 720);
|
||||
const defaultFraction = 25 / 100; // DEFAULT_WEBCAM_SIZE_PRESET = 25
|
||||
expect(layout).not.toBeNull();
|
||||
expect(layout!.webcamRect).not.toBeNull();
|
||||
expect(layout!.webcamRect!.width).toBeLessThanOrEqual(Math.round(1280 * 0.18) + 1);
|
||||
expect(layout!.webcamRect!.height).toBeLessThanOrEqual(Math.round(720 * 0.18) + 1);
|
||||
expect(layout!.webcamRect!.width).toBeLessThanOrEqual(Math.round(refDim * defaultFraction) + 1);
|
||||
expect(layout!.webcamRect!.height).toBeLessThanOrEqual(
|
||||
Math.round(refDim * defaultFraction) + 1,
|
||||
);
|
||||
expect(
|
||||
Math.abs(layout!.webcamRect!.width * 1080 - layout!.webcamRect!.height * 1920),
|
||||
).toBeLessThanOrEqual(1920);
|
||||
});
|
||||
|
||||
it("uses cover-style full-width stacking in vertical stack mode", () => {
|
||||
it("produces consistent webcam size across landscape and portrait aspect ratios", () => {
|
||||
const webcamSize = { width: 1280, height: 720 };
|
||||
const landscape = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
screenSize: { width: 1920, height: 1080 },
|
||||
webcamSize,
|
||||
webcamSizePreset: 50,
|
||||
});
|
||||
const portrait = computeCompositeLayout({
|
||||
canvasSize: { width: 1080, height: 1920 },
|
||||
screenSize: { width: 1080, height: 1920 },
|
||||
webcamSize,
|
||||
webcamSizePreset: 50,
|
||||
});
|
||||
|
||||
expect(landscape).not.toBeNull();
|
||||
expect(portrait).not.toBeNull();
|
||||
// Same total pixel count — webcam area should be comparable
|
||||
const landscapeArea = landscape!.webcamRect!.width * landscape!.webcamRect!.height;
|
||||
const portraitArea = portrait!.webcamRect!.width * portrait!.webcamRect!.height;
|
||||
expect(landscapeArea).toBe(portraitArea);
|
||||
});
|
||||
|
||||
it("scales the webcam proportionally as webcamSizePreset increases", () => {
|
||||
const canvasSize = { width: 1920, height: 1080 };
|
||||
const screenSize = { width: 1920, height: 1080 };
|
||||
const webcamSize = { width: 1280, height: 720 };
|
||||
|
||||
const small = computeCompositeLayout({
|
||||
canvasSize,
|
||||
screenSize,
|
||||
webcamSize,
|
||||
webcamSizePreset: 10,
|
||||
});
|
||||
const medium = computeCompositeLayout({
|
||||
canvasSize,
|
||||
screenSize,
|
||||
webcamSize,
|
||||
webcamSizePreset: 25,
|
||||
});
|
||||
const large = computeCompositeLayout({
|
||||
canvasSize,
|
||||
screenSize,
|
||||
webcamSize,
|
||||
webcamSizePreset: 50,
|
||||
});
|
||||
|
||||
expect(small!.webcamRect!.width).toBeLessThan(medium!.webcamRect!.width);
|
||||
expect(medium!.webcamRect!.width).toBeLessThan(large!.webcamRect!.width);
|
||||
expect(small!.webcamRect!.height).toBeLessThan(medium!.webcamRect!.height);
|
||||
expect(medium!.webcamRect!.height).toBeLessThan(large!.webcamRect!.height);
|
||||
});
|
||||
|
||||
it("clamps webcamSizePreset to the valid range (10–50)", () => {
|
||||
const canvasSize = { width: 1920, height: 1080 };
|
||||
const screenSize = { width: 1920, height: 1080 };
|
||||
const webcamSize = { width: 1280, height: 720 };
|
||||
|
||||
const atMin = computeCompositeLayout({
|
||||
canvasSize,
|
||||
screenSize,
|
||||
webcamSize,
|
||||
webcamSizePreset: 10,
|
||||
});
|
||||
const belowMin = computeCompositeLayout({
|
||||
canvasSize,
|
||||
screenSize,
|
||||
webcamSize,
|
||||
webcamSizePreset: 1,
|
||||
});
|
||||
const atMax = computeCompositeLayout({
|
||||
canvasSize,
|
||||
screenSize,
|
||||
webcamSize,
|
||||
webcamSizePreset: 50,
|
||||
});
|
||||
const aboveMax = computeCompositeLayout({
|
||||
canvasSize,
|
||||
screenSize,
|
||||
webcamSize,
|
||||
webcamSizePreset: 100,
|
||||
});
|
||||
|
||||
// Values below 10 should clamp to 10
|
||||
expect(belowMin!.webcamRect!.width).toBe(atMin!.webcamRect!.width);
|
||||
expect(belowMin!.webcamRect!.height).toBe(atMin!.webcamRect!.height);
|
||||
// Values above 50 should clamp to 50
|
||||
expect(aboveMax!.webcamRect!.width).toBe(atMax!.webcamRect!.width);
|
||||
expect(aboveMax!.webcamRect!.height).toBe(atMax!.webcamRect!.height);
|
||||
});
|
||||
|
||||
it("centers the combined screen and webcam stack in vertical stack mode", () => {
|
||||
const layout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
maxContentSize: { width: 1536, height: 864 },
|
||||
@@ -43,23 +138,19 @@ describe("computeCompositeLayout", () => {
|
||||
});
|
||||
|
||||
expect(layout).not.toBeNull();
|
||||
expect(layout?.screenRect).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 0,
|
||||
});
|
||||
expect(layout?.webcamRect).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
borderRadius: 0,
|
||||
});
|
||||
expect(layout?.screenCover).toBe(true);
|
||||
// Webcam is full-width at the bottom
|
||||
expect(layout!.webcamRect).not.toBeNull();
|
||||
expect(layout!.webcamRect!.x).toBe(0);
|
||||
expect(layout!.webcamRect!.width).toBe(1920);
|
||||
expect(layout!.webcamRect!.borderRadius).toBe(0);
|
||||
// Screen fills remaining space at the top (cover mode)
|
||||
expect(layout!.screenRect.x).toBe(0);
|
||||
expect(layout!.screenRect.y).toBe(0);
|
||||
expect(layout!.screenRect.width).toBe(1920);
|
||||
expect(layout!.screenCover).toBe(true);
|
||||
});
|
||||
|
||||
it("fills the canvas with the screen when vertical stack has no webcam", () => {
|
||||
it("keeps the screen full-canvas and omits the webcam when dimensions are unavailable in stack mode", () => {
|
||||
const layout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
maxContentSize: { width: 1536, height: 864 },
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface Size {
|
||||
}
|
||||
|
||||
export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack";
|
||||
/** Webcam size as a percentage of the canvas reference dimension (10–50). */
|
||||
export type WebcamSizePreset = number;
|
||||
|
||||
export interface WebcamLayoutShadow {
|
||||
color: string;
|
||||
@@ -32,7 +34,6 @@ interface BorderRadiusRule {
|
||||
|
||||
interface OverlayTransform {
|
||||
type: "overlay";
|
||||
maxStageFraction: number;
|
||||
marginFraction: number;
|
||||
minMargin: number;
|
||||
minSize: number;
|
||||
@@ -57,7 +58,13 @@ export interface WebcamCompositeLayout {
|
||||
screenCover?: boolean;
|
||||
}
|
||||
|
||||
const MAX_STAGE_FRACTION = 0.18;
|
||||
/** Convert a webcam size percentage (10–50) to a fraction of the reference dimension. */
|
||||
function webcamSizeToFraction(percent: number): number {
|
||||
const safe = Number.isFinite(percent) ? percent : 25;
|
||||
const clamped = Math.max(10, Math.min(50, safe));
|
||||
return clamped / 100;
|
||||
}
|
||||
|
||||
const MARGIN_FRACTION = 0.02;
|
||||
const MAX_BORDER_RADIUS = 24;
|
||||
const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDefinition> = {
|
||||
@@ -65,7 +72,6 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDef
|
||||
label: "Picture in Picture",
|
||||
transform: {
|
||||
type: "overlay",
|
||||
maxStageFraction: MAX_STAGE_FRACTION,
|
||||
marginFraction: MARGIN_FRACTION,
|
||||
minMargin: 0,
|
||||
minSize: 0,
|
||||
@@ -125,6 +131,7 @@ export function computeCompositeLayout(params: {
|
||||
screenSize: Size;
|
||||
webcamSize?: Size | null;
|
||||
layoutPreset?: WebcamLayoutPreset;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||
}): WebcamCompositeLayout | null {
|
||||
@@ -134,6 +141,7 @@ export function computeCompositeLayout(params: {
|
||||
screenSize,
|
||||
webcamSize,
|
||||
layoutPreset = "picture-in-picture",
|
||||
webcamSizePreset = 25,
|
||||
webcamPosition,
|
||||
webcamMaskShape = "rectangle",
|
||||
} = params;
|
||||
@@ -143,6 +151,8 @@ export function computeCompositeLayout(params: {
|
||||
const webcamHeight = webcamSize?.height;
|
||||
const preset = getWebcamLayoutPresetDefinition(layoutPreset);
|
||||
|
||||
const MAX_STAGE_FRACTION = webcamSizeToFraction(webcamSizePreset);
|
||||
|
||||
if (canvasWidth <= 0 || canvasHeight <= 0 || screenWidth <= 0 || screenHeight <= 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -198,8 +208,11 @@ export function computeCompositeLayout(params: {
|
||||
transform.minMargin,
|
||||
Math.round(Math.min(canvasWidth, canvasHeight) * transform.marginFraction),
|
||||
);
|
||||
const maxWidth = Math.max(transform.minSize, canvasWidth * transform.maxStageFraction);
|
||||
const maxHeight = Math.max(transform.minSize, canvasHeight * transform.maxStageFraction);
|
||||
// Use geometric mean so the webcam occupies a consistent visual proportion
|
||||
// regardless of whether the canvas is portrait or landscape.
|
||||
const referenceDim = Math.sqrt(canvasWidth * canvasHeight);
|
||||
const maxWidth = Math.max(transform.minSize, referenceDim * MAX_STAGE_FRACTION);
|
||||
const maxHeight = Math.max(transform.minSize, referenceDim * MAX_STAGE_FRACTION);
|
||||
const scale = Math.min(maxWidth / webcamWidth, maxHeight / webcamHeight);
|
||||
let width = Math.round(webcamWidth * scale);
|
||||
let height = Math.round(webcamHeight * scale);
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
CropRegion,
|
||||
SpeedRegion,
|
||||
WebcamLayoutPreset,
|
||||
WebcamSizePreset,
|
||||
ZoomDepth,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
@@ -70,6 +71,7 @@ interface FrameRenderConfig {
|
||||
webcamSize?: Size | null;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
@@ -112,6 +114,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 +195,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");
|
||||
@@ -453,6 +465,7 @@ export class FrameRenderer {
|
||||
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||
webcamSize: webcamFrame ? this.config.webcamSize : null,
|
||||
layoutPreset: this.config.webcamLayoutPreset,
|
||||
webcamSizePreset: this.config.webcamSizePreset,
|
||||
webcamPosition: this.config.webcamPosition,
|
||||
webcamMaskShape: this.config.webcamMaskShape,
|
||||
});
|
||||
@@ -675,10 +688,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 +844,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);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
SpeedRegion,
|
||||
TrimRegion,
|
||||
WebcamLayoutPreset,
|
||||
WebcamSizePreset,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||
@@ -42,6 +43,7 @@ interface GifExporterConfig {
|
||||
cropRegion: CropRegion;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
@@ -144,6 +146,7 @@ export class GifExporter {
|
||||
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
|
||||
webcamLayoutPreset: this.config.webcamLayoutPreset,
|
||||
webcamMaskShape: this.config.webcamMaskShape,
|
||||
webcamSizePreset: this.config.webcamSizePreset,
|
||||
webcamPosition: this.config.webcamPosition,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
speedRegions: this.config.speedRegions,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
SpeedRegion,
|
||||
TrimRegion,
|
||||
WebcamLayoutPreset,
|
||||
WebcamSizePreset,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||
@@ -33,6 +34,7 @@ interface VideoExporterConfig extends ExportConfig {
|
||||
cropRegion: CropRegion;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
@@ -137,6 +139,7 @@ export class VideoExporter {
|
||||
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
|
||||
webcamLayoutPreset: this.config.webcamLayoutPreset,
|
||||
webcamMaskShape: this.config.webcamMaskShape,
|
||||
webcamSizePreset: this.config.webcamSizePreset,
|
||||
webcamPosition: this.config.webcamPosition,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
speedRegions: this.config.speedRegions,
|
||||
|
||||
Vendored
+2
@@ -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>;
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
Reference in New Issue
Block a user