merge: resolve conflicts and update video playback system
This commit is contained in:
+14
-5
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx";
|
||||
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
||||
import { SourceSelector } from "./components/launch/SourceSelector";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
@@ -9,18 +10,24 @@ import { ShortcutsProvider } from "./contexts/ShortcutsContext";
|
||||
import { loadAllCustomFonts } from "./lib/customFonts";
|
||||
|
||||
export default function App() {
|
||||
const [windowType, setWindowType] = useState("");
|
||||
const [windowType, setWindowType] = useState(
|
||||
() => new URLSearchParams(window.location.search).get("windowType") || "",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const type = params.get("windowType") || "";
|
||||
setWindowType(type);
|
||||
if (type === "hud-overlay" || type === "source-selector") {
|
||||
const type = new URLSearchParams(window.location.search).get("windowType") || "";
|
||||
if (type !== windowType) {
|
||||
setWindowType(type);
|
||||
}
|
||||
|
||||
if (type === "hud-overlay" || type === "source-selector" || type === "countdown-overlay") {
|
||||
document.body.style.background = "transparent";
|
||||
document.documentElement.style.background = "transparent";
|
||||
document.getElementById("root")?.style.setProperty("background", "transparent");
|
||||
}
|
||||
}, [windowType]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load custom fonts on app initialization
|
||||
loadAllCustomFonts().catch((error) => {
|
||||
console.error("Failed to load custom fonts:", error);
|
||||
@@ -33,6 +40,8 @@ export default function App() {
|
||||
return <LaunchWindow />;
|
||||
case "source-selector":
|
||||
return <SourceSelector />;
|
||||
case "countdown-overlay":
|
||||
return <CountdownOverlay />;
|
||||
case "editor":
|
||||
return (
|
||||
<ShortcutsProvider>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function CountdownOverlay() {
|
||||
const [value, setValue] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electronAPI.onCountdownOverlayValue((nextValue) => {
|
||||
setValue(nextValue);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen bg-transparent flex items-center justify-center pointer-events-none select-none">
|
||||
<div className="flex items-center justify-center w-40 h-40 rounded-full bg-black/50">
|
||||
<div
|
||||
className="text-white/90 text-[80px] font-bold leading-none tabular-nums"
|
||||
style={{ textShadow: "0 4px 24px rgba(0, 0, 0, 0.65)" }}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,3 +6,78 @@
|
||||
.electronNoDrag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.languageMenuScroll {
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.languageMenuScroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.languageMenuScroll::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.languageMenuScroll::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0.2));
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.languageMenuScroll::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.3));
|
||||
}
|
||||
|
||||
.languageMenuContainer {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.languageMenuPanel {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 12rem;
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: linear-gradient(160deg, rgba(28, 29, 42, 0.98), rgba(18, 19, 28, 0.98));
|
||||
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(14px);
|
||||
pointer-events: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.languageMenuItem {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.languageMenuItem:hover,
|
||||
.languageMenuItem:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.languageMenuItemActive {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChevronDown, Languages } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, ChevronDown, Languages } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs";
|
||||
import { FaRegStopCircle } from "react-icons/fa";
|
||||
import { FaFolderOpen } from "react-icons/fa6";
|
||||
@@ -18,9 +19,7 @@ import {
|
||||
} from "react-icons/md";
|
||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||
import { useI18n, useScopedT } from "@/contexts/I18nContext";
|
||||
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
import { getLocaleName } from "@/i18n/loader";
|
||||
import { isMac as getIsMac } from "@/utils/platformUtils";
|
||||
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
|
||||
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||
import { useCameraDevices } from "../../hooks/useCameraDevices";
|
||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||
@@ -28,6 +27,7 @@ import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
||||
import { requestCameraAccess } from "../../lib/requestCameraAccess";
|
||||
import { formatTimePadded } from "../../utils/timeUtils";
|
||||
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
||||
import { Button } from "../ui/button";
|
||||
import { Tooltip } from "../ui/tooltip";
|
||||
import styles from "./LaunchWindow.module.css";
|
||||
|
||||
@@ -67,17 +67,26 @@ const hudGroupClasses =
|
||||
const hudIconBtnClasses =
|
||||
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer text-white hover:bg-white/10 hover:scale-[1.08] active:scale-95";
|
||||
|
||||
const hudAuxIconBtnClasses =
|
||||
"flex items-center justify-center p-1.5 rounded-full transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed";
|
||||
|
||||
const windowBtnClasses =
|
||||
"flex items-center justify-center p-2 rounded-full transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]";
|
||||
|
||||
const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5";
|
||||
|
||||
export function LaunchWindow() {
|
||||
const t = useScopedT("launch");
|
||||
const { locale, setLocale } = useI18n();
|
||||
const [isMac, setIsMac] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getIsMac().then(setIsMac);
|
||||
}, []);
|
||||
const availableLocales = getAvailableLocales();
|
||||
const {
|
||||
locale,
|
||||
setLocale,
|
||||
systemLocaleSuggestion,
|
||||
acceptSystemLocaleSuggestion,
|
||||
dismissSystemLocaleSuggestion,
|
||||
resolveSystemLocaleSuggestion,
|
||||
} = useI18n();
|
||||
const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : "";
|
||||
|
||||
const {
|
||||
recording,
|
||||
@@ -109,6 +118,18 @@ export function LaunchWindow() {
|
||||
const [isWebcamHovered, setIsWebcamHovered] = useState(false);
|
||||
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
|
||||
const webcamExpanded = isWebcamHovered || isWebcamFocused;
|
||||
const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
|
||||
const languageTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [languageMenuStyle, setLanguageMenuStyle] = useState<{
|
||||
right: number;
|
||||
top: number;
|
||||
maxHeight: number;
|
||||
}>({
|
||||
right: 12,
|
||||
top: 12,
|
||||
maxHeight: 240,
|
||||
});
|
||||
|
||||
const {
|
||||
devices: micDevices,
|
||||
@@ -162,6 +183,71 @@ export function LaunchWindow() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLanguageMenuOpen) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
const clickedTrigger = languageTriggerRef.current?.contains(target);
|
||||
const clickedMenu = languageMenuPanelRef.current?.contains(target);
|
||||
if (!clickedTrigger && !clickedMenu) {
|
||||
setIsLanguageMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsLanguageMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("pointerdown", handlePointerDown);
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", handlePointerDown);
|
||||
window.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [isLanguageMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLanguageMenuOpen || !languageTriggerRef.current) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!languageTriggerRef.current) return;
|
||||
const rect = languageTriggerRef.current.getBoundingClientRect();
|
||||
const gap = 8;
|
||||
const viewportPadding = 8;
|
||||
const availableHeight = Math.max(80, rect.top - viewportPadding - gap);
|
||||
const top = Math.max(viewportPadding, rect.top - gap - availableHeight);
|
||||
|
||||
setLanguageMenuStyle({
|
||||
right: Math.max(viewportPadding, window.innerWidth - rect.right),
|
||||
top,
|
||||
maxHeight: availableHeight,
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [isLanguageMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLanguageMenuOpen || !languageMenuPanelRef.current) return;
|
||||
const id = requestAnimationFrame(() => {
|
||||
if (languageMenuPanelRef.current) {
|
||||
languageMenuPanelRef.current.scrollTop = 0;
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [isLanguageMenuOpen]);
|
||||
|
||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
|
||||
@@ -228,25 +314,42 @@ export function LaunchWindow() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-screen h-screen bg-transparent ${styles.electronDrag}`}>
|
||||
{/* Language switcher — top-left, beside traffic lights */}
|
||||
<div
|
||||
className={`fixed top-2 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 ${isMac ? "left-[72px]" : "left-2"} ${styles.electronNoDrag}`}
|
||||
>
|
||||
<Languages size={14} />
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
|
||||
style={{ color: "inherit" }}
|
||||
<div className={`w-screen h-screen overflow-x-hidden bg-transparent ${styles.electronDrag}`}>
|
||||
{systemLocaleSuggestion && (
|
||||
<div
|
||||
className={`fixed top-8 left-1/2 z-30 w-[calc(100vw-1rem)] max-w-[520px] -translate-x-1/2 rounded-xl border border-white/15 bg-[rgba(20,20,28,0.95)] p-3 shadow-2xl backdrop-blur-xl text-white animate-in fade-in-0 zoom-in-95 duration-200 ${styles.electronNoDrag}`}
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((loc) => (
|
||||
<option key={loc} value={loc} className="bg-[#1c1c24] text-white">
|
||||
{getLocaleName(loc)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-[13px] font-semibold text-white">
|
||||
{t("systemLanguagePrompt.title")}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-white/75">
|
||||
{t("systemLanguagePrompt.description", {
|
||||
language: suggestedLanguageName,
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={dismissSystemLocaleSuggestion}
|
||||
className="h-7 text-xs text-white/80 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{t("systemLanguagePrompt.keepDefault")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={acceptSystemLocaleSuggestion}
|
||||
className="h-7 text-xs bg-white text-[#10121b] hover:bg-white/90"
|
||||
>
|
||||
{t("systemLanguagePrompt.switch", {
|
||||
language: suggestedLanguageName,
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */}
|
||||
{(showMicControls || showWebcamControls) && (
|
||||
@@ -423,6 +526,7 @@ export function LaunchWindow() {
|
||||
onClick={async () => {
|
||||
await setWebcamEnabled(!webcamEnabled);
|
||||
}}
|
||||
disabled={recording}
|
||||
title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")}
|
||||
>
|
||||
{webcamEnabled
|
||||
@@ -433,104 +537,151 @@ export function LaunchWindow() {
|
||||
|
||||
{/* Record/Stop group */}
|
||||
<button
|
||||
className={`flex items-center gap-0.5 rounded-full p-2 transition-colors duration-150 ${styles.electronNoDrag} ${
|
||||
className={`flex items-center justify-center rounded-full p-2 transition-[min-width,background-color] duration-150 ${recording ? "min-w-[78px]" : "min-w-[36px]"} ${styles.electronNoDrag} ${
|
||||
recording
|
||||
? paused
|
||||
? "bg-amber-500/10 hover:bg-amber-500/15"
|
||||
: "animate-record-pulse bg-red-500/10"
|
||||
: "bg-red-500/12 hover:bg-red-500/16"
|
||||
: "bg-white/5 hover:bg-white/[0.08]"
|
||||
}`}
|
||||
onClick={toggleRecording}
|
||||
disabled={!hasSelectedSource && !recording}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
{getIcon("stop", paused ? "text-amber-400" : "text-red-400")}
|
||||
<div className={`flex items-center justify-center ${recording ? "gap-1.5" : ""}`}>
|
||||
{recording
|
||||
? getIcon("stop", paused ? "text-amber-400" : "text-red-400")
|
||||
: getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")}
|
||||
{recording && (
|
||||
<span
|
||||
className={`${paused ? "text-amber-400" : "text-red-400"} text-xs font-semibold tabular-nums`}
|
||||
className={`${paused ? "text-amber-400" : "text-red-400"} inline-block w-[34px] text-left text-xs font-semibold tabular-nums`}
|
||||
>
|
||||
{formatTimePadded(elapsedSeconds)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
getIcon("record", hasSelectedSource ? "text-white/80" : "text-white/30")
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{recording && (
|
||||
<Tooltip content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={togglePaused}
|
||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||
<Tooltip
|
||||
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
|
||||
>
|
||||
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button className={hudAuxIconBtnClasses} onClick={togglePaused}>
|
||||
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("tooltips.restartRecording")}>
|
||||
<button className={hudAuxIconBtnClasses} onClick={restartRecording}>
|
||||
{getIcon("restart", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("tooltips.cancelRecording")}>
|
||||
<button className={hudAuxIconBtnClasses} onClick={cancelRecording}>
|
||||
{getIcon("cancel", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restart recording */}
|
||||
{recording && (
|
||||
<Tooltip content={t("tooltips.restartRecording")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={restartRecording}
|
||||
>
|
||||
{getIcon("restart", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{!recording && (
|
||||
<>
|
||||
{/* Open video file */}
|
||||
<Tooltip content={t("tooltips.openVideoFile")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openVideoFile}
|
||||
>
|
||||
{getIcon("videoFile", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Open project */}
|
||||
<Tooltip content={t("tooltips.openProject")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openProjectFile}
|
||||
>
|
||||
{getIcon("folder", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cancel recording */}
|
||||
{recording && (
|
||||
<Tooltip content={t("tooltips.cancelRecording")}>
|
||||
{/* Right sidebar controls */}
|
||||
<div className={`${hudSidebarClasses} ${styles.electronNoDrag}`}>
|
||||
<div className={`${styles.languageMenuContainer} ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={cancelRecording}
|
||||
ref={languageTriggerRef}
|
||||
type="button"
|
||||
aria-label={t("language")}
|
||||
aria-expanded={isLanguageMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setIsLanguageMenuOpen((open) => !open)}
|
||||
className={`h-8 w-8 rounded-lg border border-white/10 bg-white/5 text-white/85 shadow-none transition-colors hover:bg-white/10 ${styles.electronNoDrag}`}
|
||||
>
|
||||
{getIcon("cancel", "text-white/60")}
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<Languages size={13} className="text-white/75" />
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open video file */}
|
||||
<Tooltip content={t("tooltips.openVideoFile")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openVideoFile}
|
||||
disabled={recording}
|
||||
>
|
||||
{getIcon("videoFile", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{isLanguageMenuOpen
|
||||
? createPortal(
|
||||
<div
|
||||
ref={languageMenuPanelRef}
|
||||
role="menu"
|
||||
className={`${styles.languageMenuPanel} ${styles.languageMenuScroll} ${styles.electronNoDrag}`}
|
||||
style={
|
||||
{
|
||||
WebkitAppRegion: "no-drag",
|
||||
pointerEvents: "auto",
|
||||
right: `${languageMenuStyle.right}px`,
|
||||
top: `${languageMenuStyle.top}px`,
|
||||
maxHeight: `${languageMenuStyle.maxHeight}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{availableLocales.map((loc) => (
|
||||
<button
|
||||
key={loc}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={loc === locale}
|
||||
onClick={() => {
|
||||
setLocale(loc);
|
||||
resolveSystemLocaleSuggestion();
|
||||
setIsLanguageMenuOpen(false);
|
||||
}}
|
||||
className={`${styles.languageMenuItem} ${loc === locale ? styles.languageMenuItemActive : ""}`}
|
||||
>
|
||||
<span className="truncate">{getLocaleName(loc)}</span>
|
||||
{loc === locale ? <Check size={11} className="text-white/85" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{/* Open project */}
|
||||
<Tooltip content={t("tooltips.openProject")}>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
|
||||
onClick={openProjectFile}
|
||||
disabled={recording}
|
||||
>
|
||||
{getIcon("folder", "text-white/60")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Window controls */}
|
||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.hideHUD")}
|
||||
onClick={sendHudOverlayHide}
|
||||
>
|
||||
{getIcon("minimize", "text-white")}
|
||||
</button>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.closeApp")}
|
||||
onClick={sendHudOverlayClose}
|
||||
>
|
||||
{getIcon("close", "text-white")}
|
||||
</button>
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.hideHUD")}
|
||||
onClick={sendHudOverlayHide}
|
||||
>
|
||||
{getIcon("minimize", "text-white")}
|
||||
</button>
|
||||
<button
|
||||
className={windowBtnClasses}
|
||||
title={t("tooltips.closeApp")}
|
||||
onClick={sendHudOverlayClose}
|
||||
>
|
||||
{getIcon("close", "text-white")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
background: linear-gradient(135deg, rgba(28, 28, 34, 0.92) 0%, rgba(18, 18, 22, 0.88) 100%);
|
||||
backdrop-filter: blur(20px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(160%);
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
0 4px 16px 0 rgba(0, 0, 0, 0.32),
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.18) inset;
|
||||
border: 1px solid rgba(60, 60, 80, 0.18);
|
||||
border-radius: 30px;
|
||||
corner-shape: squircle;
|
||||
/*
|
||||
Removed box-shadow here because electron doesn't round corners of the shadow, thereby leaving a square border shadow conflicting with the rounded corners of the SourceSelector.
|
||||
The result is easily visible when you place a white window just behind the SourceSelector
|
||||
*/
|
||||
/* box-shadow:
|
||||
0 0px 16px 0 rgba(0, 0, 0, 0.32),
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.18) inset; */
|
||||
border: 1.5px solid rgba(60, 60, 80, 0.3);
|
||||
}
|
||||
|
||||
.sourceCard {
|
||||
border-radius: 12px;
|
||||
corner-shape: squircle;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(120deg, rgba(38, 38, 48, 0.98) 0%, rgba(24, 24, 32, 0.96) 100%);
|
||||
border: 1px solid rgba(60, 60, 80, 0.22);
|
||||
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.18);
|
||||
@@ -28,7 +34,7 @@
|
||||
}
|
||||
|
||||
.selected {
|
||||
border: 2px solid #34b27b;
|
||||
border: 1.5px solid #34b27b;
|
||||
background: linear-gradient(120deg, rgba(52, 178, 123, 0.08) 0%, rgba(38, 38, 48, 0.98) 100%);
|
||||
box-shadow:
|
||||
0 0 12px rgba(52, 178, 123, 0.15),
|
||||
@@ -70,30 +76,27 @@
|
||||
}
|
||||
|
||||
/* scrollbar */
|
||||
.sourceGridScroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(52, 178, 123, 0.5) rgba(40, 40, 50, 0.6);
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-track {
|
||||
background: rgba(30, 30, 38, 0.5);
|
||||
background: rgba(30, 30, 38, 0.3);
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(80, 80, 100, 0.6);
|
||||
border-radius: 4px;
|
||||
background: rgba(52, 178, 123, 0.5);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(52, 178, 123, 0.6);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-thumb:active {
|
||||
background: rgba(52, 178, 123, 0.8);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function SourceSelector() {
|
||||
style={{ minHeight: "100vh" }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#34B27B] mx-auto mb-2" />
|
||||
<div className="animate-spin duration-500 rounded-[50%] h-6 w-6 border-2 border-b-transparent border-[#34B27B] mx-auto mb-2" />
|
||||
<p className="text-xs text-zinc-400">{t("sourceSelector.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,10 +84,10 @@ export function SourceSelector() {
|
||||
<img
|
||||
src={source.thumbnail || ""}
|
||||
alt={source.name}
|
||||
className="w-full aspect-video object-cover rounded-lg"
|
||||
className="w-full aspect-video object-cover rounded-xl [corner-shape:squircle] "
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute -top-1.5 -right-1.5">
|
||||
<div className="absolute -top-1 -right-1">
|
||||
<div className={styles.checkBadge}>
|
||||
<MdCheck size={12} className="text-white" />
|
||||
</div>
|
||||
@@ -111,16 +111,16 @@ export function SourceSelector() {
|
||||
defaultValue={screenSources.length === 0 ? "windows" : "screens"}
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-full">
|
||||
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-[14px] squircle ">
|
||||
<TabsTrigger
|
||||
value="screens"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-[12px] squircle text-xs py-1.5 transition-all"
|
||||
>
|
||||
{t("sourceSelector.screens", { count: String(screenSources.length) })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="windows"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-[12px] squircle text-xs py-1.5 transition-all"
|
||||
>
|
||||
{t("sourceSelector.windows", { count: String(windowSources.length) })}
|
||||
</TabsTrigger>
|
||||
@@ -128,14 +128,14 @@ export function SourceSelector() {
|
||||
<div className="flex-1 min-h-0">
|
||||
<TabsContent value="screens" className="h-full mt-0">
|
||||
<div
|
||||
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
|
||||
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pt-1 pr-1.5 auto-rows-min ${styles.sourceGridScroll}`}
|
||||
>
|
||||
{screenSources.map(renderSourceCard)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="windows" className="h-full mt-0">
|
||||
<div
|
||||
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
|
||||
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pt-1 pr-1.5 auto-rows-min ${styles.sourceGridScroll}`}
|
||||
>
|
||||
{windowSources.map(renderSourceCard)}
|
||||
</div>
|
||||
@@ -143,18 +143,18 @@ export function SourceSelector() {
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="p-3 flex justify-center gap-2">
|
||||
<div className="p-3 justify-center flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => window.close()}
|
||||
className="px-5 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/5 rounded-full"
|
||||
className="px-5 py-1 text-xs text-zinc-400 hover:text-white active:scale-95 transition-transform duration-150 hover:bg-white/5 rounded-full"
|
||||
>
|
||||
{tc("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleShare}
|
||||
disabled={!selectedSource}
|
||||
className="px-5 py-1 text-xs bg-[#34B27B] text-white hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
|
||||
className="px-5 py-1 text-xs bg-[#34B27B] text-white active:scale-95 transition-transform duration-150 hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
|
||||
>
|
||||
{tc("actions.share")}
|
||||
</Button>
|
||||
|
||||
@@ -52,4 +52,4 @@ const AccordionContent = React.forwardRef<
|
||||
));
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
||||
|
||||
@@ -52,4 +52,4 @@ const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
|
||||
@@ -90,13 +90,13 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
|
||||
@@ -54,9 +54,11 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
|
||||
portalled?: boolean;
|
||||
}
|
||||
>(({ className, sideOffset = 4, portalled = true, ...props }, ref) => {
|
||||
const content = (
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
@@ -67,8 +69,14 @@ const DropdownMenuContent = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
);
|
||||
|
||||
if (!portalled) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <DropdownMenuPrimitive.Portal>{content}</DropdownMenuPrimitive.Portal>;
|
||||
});
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
@@ -169,18 +177,18 @@ DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
|
||||
@@ -57,4 +57,4 @@ function PopoverArrow({
|
||||
);
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow };
|
||||
export { Popover, PopoverAnchor, PopoverArrow, PopoverContent, PopoverTrigger };
|
||||
|
||||
@@ -62,34 +62,50 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {
|
||||
showScrollButtons?: boolean;
|
||||
viewportClassName?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
showScrollButtons = true,
|
||||
viewportClassName,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-1",
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
{showScrollButtons ? <SelectScrollUpButton /> : null}
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"max-h-[var(--radix-select-content-available-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
viewportClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
{showScrollButtons ? <SelectScrollDownButton /> : null}
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
),
|
||||
);
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
@@ -141,13 +157,13 @@ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
|
||||
@@ -50,4 +50,4 @@ const TabsContent = React.forwardRef<
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
|
||||
@@ -67,4 +67,4 @@ function Tooltip({
|
||||
);
|
||||
}
|
||||
|
||||
export { TooltipProvider, TooltipRoot, TooltipTrigger, TooltipContent, Tooltip };
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger };
|
||||
|
||||
@@ -1,8 +1,40 @@
|
||||
import { useRef } from "react";
|
||||
import { type CSSProperties, type PointerEvent, useEffect, useRef, useState } from "react";
|
||||
import { Rnd } from "react-rnd";
|
||||
import {
|
||||
getBlurOverlayColor,
|
||||
getMosaicGridOverlayColor,
|
||||
getNormalizedMosaicBlockSize,
|
||||
} from "@/lib/blurEffects";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getArrowComponent } from "./ArrowSvgs";
|
||||
import type { AnnotationRegion } from "./types";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
DEFAULT_BLUR_BLOCK_SIZE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
} from "./types";
|
||||
|
||||
const FREEHAND_POINT_THRESHOLD = 1;
|
||||
type PreviewCanvasSource = {
|
||||
width: number;
|
||||
height: number;
|
||||
clientWidth?: number;
|
||||
clientHeight?: number;
|
||||
};
|
||||
|
||||
function buildBlurPolygonClipPath(points: Array<{ x: number; y: number }>) {
|
||||
if (points.length < 3) return undefined;
|
||||
const polygon = points.map((point) => `${point.x}% ${point.y}%`).join(", ");
|
||||
return `polygon(${polygon})`;
|
||||
}
|
||||
|
||||
function buildBlurFreehandPath(points: Array<{ x: number; y: number }>, closed = true) {
|
||||
if (closed ? points.length < 3 : points.length < 2) return null;
|
||||
const [firstPoint, ...rest] = points;
|
||||
const path = `M ${firstPoint.x} ${firstPoint.y} ${rest.map((point) => `L ${point.x} ${point.y}`).join(" ")}`;
|
||||
return closed ? `${path} Z` : path;
|
||||
}
|
||||
|
||||
interface AnnotationOverlayProps {
|
||||
annotation: AnnotationRegion;
|
||||
@@ -11,9 +43,13 @@ interface AnnotationOverlayProps {
|
||||
containerHeight: number;
|
||||
onPositionChange: (id: string, position: { x: number; y: number }) => void;
|
||||
onSizeChange: (id: string, size: { width: number; height: number }) => void;
|
||||
onBlurDataChange?: (id: string, blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
onClick: (id: string) => void;
|
||||
zIndex: number;
|
||||
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
|
||||
previewSourceCanvas?: PreviewCanvasSource | null;
|
||||
previewFrameVersion?: number;
|
||||
}
|
||||
|
||||
export function AnnotationOverlay({
|
||||
@@ -23,16 +59,130 @@ export function AnnotationOverlay({
|
||||
containerHeight,
|
||||
onPositionChange,
|
||||
onSizeChange,
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
onClick,
|
||||
zIndex,
|
||||
isSelectedBoost,
|
||||
previewSourceCanvas,
|
||||
previewFrameVersion,
|
||||
}: AnnotationOverlayProps) {
|
||||
const x = (annotation.position.x / 100) * containerWidth;
|
||||
const y = (annotation.position.y / 100) * containerHeight;
|
||||
const width = (annotation.size.width / 100) * containerWidth;
|
||||
const height = (annotation.size.height / 100) * containerHeight;
|
||||
|
||||
const committedX = (annotation.position.x / 100) * containerWidth;
|
||||
const committedY = (annotation.position.y / 100) * containerHeight;
|
||||
const committedWidth = (annotation.size.width / 100) * containerWidth;
|
||||
const committedHeight = (annotation.size.height / 100) * containerHeight;
|
||||
const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null;
|
||||
const isSelectedFreehandBlur = isSelected && blurShape === "freehand";
|
||||
const isDraggingRef = useRef(false);
|
||||
const isDrawingFreehandRef = useRef(false);
|
||||
const freehandPointsRef = useRef<Array<{ x: number; y: number }>>([]);
|
||||
const [isFreehandDrawing, setIsFreehandDrawing] = useState(false);
|
||||
const [draftFreehandPoints, setDraftFreehandPoints] = useState<Array<{ x: number; y: number }>>(
|
||||
[],
|
||||
);
|
||||
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
|
||||
const mosaicCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const blurType = annotation.type === "blur" ? (annotation.blurData?.type ?? "blur") : "blur";
|
||||
const blurOverlayColor =
|
||||
annotation.type === "blur" ? getBlurOverlayColor(annotation.blurData) : "";
|
||||
const mosaicGridOverlayColor =
|
||||
annotation.type === "blur" ? getMosaicGridOverlayColor(annotation.blurData) : "";
|
||||
const [liveRect, setLiveRect] = useState({
|
||||
x: committedX,
|
||||
y: committedY,
|
||||
width: committedWidth,
|
||||
height: committedHeight,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLiveRect({
|
||||
x: committedX,
|
||||
y: committedY,
|
||||
width: committedWidth,
|
||||
height: committedHeight,
|
||||
});
|
||||
}, [committedHeight, committedWidth, committedX, committedY]);
|
||||
|
||||
const { x, y, width, height } = liveRect;
|
||||
|
||||
useEffect(() => {
|
||||
if (annotation.type !== "blur" || blurType !== "mosaic") {
|
||||
return;
|
||||
}
|
||||
void previewFrameVersion;
|
||||
|
||||
const canvas = mosaicCanvasRef.current;
|
||||
const sourceCanvas = previewSourceCanvas;
|
||||
if (!canvas || !sourceCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceWidth = sourceCanvas.width;
|
||||
const sourceHeight = sourceCanvas.height;
|
||||
const sourceClientWidth = sourceCanvas.clientWidth || containerWidth || sourceWidth;
|
||||
const sourceClientHeight = sourceCanvas.clientHeight || containerHeight || sourceHeight;
|
||||
if (
|
||||
sourceWidth <= 0 ||
|
||||
sourceHeight <= 0 ||
|
||||
sourceClientWidth <= 0 ||
|
||||
sourceClientHeight <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const drawWidth = Math.max(1, Math.round(width));
|
||||
const drawHeight = Math.max(1, Math.round(height));
|
||||
if (drawWidth <= 0 || drawHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.width = drawWidth;
|
||||
canvas.height = drawHeight;
|
||||
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scaleX = sourceWidth / sourceClientWidth;
|
||||
const scaleY = sourceHeight / sourceClientHeight;
|
||||
const sourceX = Math.max(0, Math.floor(x * scaleX));
|
||||
const sourceY = Math.max(0, Math.floor(y * scaleY));
|
||||
const sourceSampleWidth = Math.max(1, Math.ceil(drawWidth * scaleX));
|
||||
const sourceSampleHeight = Math.max(1, Math.ceil(drawHeight * scaleY));
|
||||
const clampedSampleWidth = Math.max(1, Math.min(sourceSampleWidth, sourceWidth - sourceX));
|
||||
const clampedSampleHeight = Math.max(1, Math.min(sourceSampleHeight, sourceHeight - sourceY));
|
||||
const blockSize = getNormalizedMosaicBlockSize(annotation.blurData);
|
||||
const downscaledWidth = Math.max(1, Math.round(drawWidth / blockSize));
|
||||
const downscaledHeight = Math.max(1, Math.round(drawHeight / blockSize));
|
||||
canvas.width = downscaledWidth;
|
||||
canvas.height = downscaledHeight;
|
||||
|
||||
context.clearRect(0, 0, downscaledWidth, downscaledHeight);
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.drawImage(
|
||||
sourceCanvas as CanvasImageSource,
|
||||
sourceX,
|
||||
sourceY,
|
||||
clampedSampleWidth,
|
||||
clampedSampleHeight,
|
||||
0,
|
||||
0,
|
||||
downscaledWidth,
|
||||
downscaledHeight,
|
||||
);
|
||||
}, [
|
||||
annotation,
|
||||
blurType,
|
||||
containerHeight,
|
||||
containerWidth,
|
||||
height,
|
||||
previewFrameVersion,
|
||||
previewSourceCanvas,
|
||||
width,
|
||||
x,
|
||||
y,
|
||||
]);
|
||||
|
||||
const renderArrow = () => {
|
||||
const direction = annotation.figureData?.arrowDirection || "right";
|
||||
@@ -43,6 +193,95 @@ export function AnnotationOverlay({
|
||||
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
|
||||
};
|
||||
|
||||
const normalizePoint = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = ((event.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((event.clientY - rect.top) / rect.height) * 100;
|
||||
return {
|
||||
x: Math.max(0, Math.min(100, x)),
|
||||
y: Math.max(0, Math.min(100, y)),
|
||||
};
|
||||
};
|
||||
|
||||
const appendFreehandPoint = (point: { x: number; y: number }) => {
|
||||
const points = freehandPointsRef.current;
|
||||
const lastPoint = points[points.length - 1];
|
||||
if (!lastPoint) {
|
||||
points.push(point);
|
||||
return;
|
||||
}
|
||||
const dx = point.x - lastPoint.x;
|
||||
const dy = point.y - lastPoint.y;
|
||||
// Sample freehand points in annotation-space percent units to avoid overly dense paths.
|
||||
if (Math.hypot(dx, dy) >= FREEHAND_POINT_THRESHOLD) {
|
||||
points.push(point);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFreehandPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!isSelected ||
|
||||
annotation.type !== "blur" ||
|
||||
annotation.blurData?.shape !== "freehand" ||
|
||||
!onBlurDataChange
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
isDrawingFreehandRef.current = true;
|
||||
setIsFreehandDrawing(true);
|
||||
const point = normalizePoint(event);
|
||||
freehandPointsRef.current = [point];
|
||||
setDraftFreehandPoints([point]);
|
||||
setLivePointerPoint(point);
|
||||
};
|
||||
|
||||
const handleFreehandPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!isDrawingFreehandRef.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const point = normalizePoint(event);
|
||||
setLivePointerPoint(point);
|
||||
appendFreehandPoint(point);
|
||||
setDraftFreehandPoints([...freehandPointsRef.current]);
|
||||
};
|
||||
|
||||
const finishFreehandPointer = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!isDrawingFreehandRef.current || !onBlurDataChange) return;
|
||||
isDrawingFreehandRef.current = false;
|
||||
setIsFreehandDrawing(false);
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// no-op if already released
|
||||
}
|
||||
const points = [...freehandPointsRef.current];
|
||||
if (livePointerPoint) {
|
||||
const last = points[points.length - 1];
|
||||
if (!last || Math.hypot(last.x - livePointerPoint.x, last.y - livePointerPoint.y) > 0.001) {
|
||||
points.push(livePointerPoint);
|
||||
}
|
||||
}
|
||||
if (points.length >= 3) {
|
||||
const closedPoints = [...points];
|
||||
const first = closedPoints[0];
|
||||
const last = closedPoints[closedPoints.length - 1];
|
||||
if (Math.hypot(last.x - first.x, last.y - first.y) > 0.001) {
|
||||
closedPoints.push({ ...first });
|
||||
}
|
||||
onBlurDataChange(annotation.id, {
|
||||
...(annotation.blurData || { ...DEFAULT_BLUR_DATA, shape: "freehand" }),
|
||||
shape: "freehand",
|
||||
freehandPoints: closedPoints,
|
||||
});
|
||||
setDraftFreehandPoints(closedPoints);
|
||||
onBlurDataCommit?.();
|
||||
}
|
||||
setLivePointerPoint(null);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (annotation.type) {
|
||||
case "text":
|
||||
@@ -113,6 +352,149 @@ export function AnnotationOverlay({
|
||||
<div className="w-full h-full flex items-center justify-center p-2">{renderArrow()}</div>
|
||||
);
|
||||
|
||||
case "blur": {
|
||||
const shape = annotation.blurData?.shape ?? "rectangle";
|
||||
const blurIntensity = Math.max(
|
||||
1,
|
||||
Math.round(annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY),
|
||||
);
|
||||
const blockSize = Math.max(
|
||||
1,
|
||||
Math.round(annotation.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE),
|
||||
);
|
||||
const activeFreehandPoints =
|
||||
shape === "freehand"
|
||||
? isFreehandDrawing
|
||||
? draftFreehandPoints
|
||||
: (annotation.blurData?.freehandPoints ?? [])
|
||||
: [];
|
||||
const drawingPoints =
|
||||
isFreehandDrawing && livePointerPoint
|
||||
? (() => {
|
||||
const last = activeFreehandPoints[activeFreehandPoints.length - 1];
|
||||
if (!last) return [livePointerPoint];
|
||||
const dx = livePointerPoint.x - last.x;
|
||||
const dy = livePointerPoint.y - last.y;
|
||||
return Math.hypot(dx, dy) > 0.01
|
||||
? [...activeFreehandPoints, livePointerPoint]
|
||||
: activeFreehandPoints;
|
||||
})()
|
||||
: activeFreehandPoints;
|
||||
const clipPath =
|
||||
shape === "freehand" ? buildBlurPolygonClipPath(activeFreehandPoints) : undefined;
|
||||
const freehandPath =
|
||||
shape === "freehand"
|
||||
? buildBlurFreehandPath(
|
||||
isFreehandDrawing ? drawingPoints : activeFreehandPoints,
|
||||
!isFreehandDrawing,
|
||||
)
|
||||
: null;
|
||||
const currentPointerPoint = isFreehandDrawing
|
||||
? livePointerPoint || drawingPoints[drawingPoints.length - 1] || null
|
||||
: null;
|
||||
const shapeBorderRadius = shape === "oval" ? "50%" : shape === "rectangle" ? "8px" : "0";
|
||||
const shouldShowFreehandBlurFill =
|
||||
shape !== "freehand" || (!!clipPath && !isFreehandDrawing);
|
||||
const shapeMaskStyle: CSSProperties = {
|
||||
borderRadius: shapeBorderRadius,
|
||||
clipPath: isFreehandDrawing ? undefined : clipPath,
|
||||
WebkitClipPath: isFreehandDrawing ? undefined : clipPath,
|
||||
};
|
||||
const isFreehandSelected = isSelectedFreehandBlur;
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{
|
||||
...shapeMaskStyle,
|
||||
isolation: "isolate",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
...shapeMaskStyle,
|
||||
backdropFilter: blurType === "mosaic" ? "none" : `blur(${blurIntensity}px)`,
|
||||
WebkitBackdropFilter: blurType === "mosaic" ? "none" : `blur(${blurIntensity}px)`,
|
||||
backgroundColor: blurOverlayColor,
|
||||
opacity: shouldShowFreehandBlurFill ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
{blurType === "mosaic" && shouldShowFreehandBlurFill && (
|
||||
<canvas
|
||||
ref={mosaicCanvasRef}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{
|
||||
...shapeMaskStyle,
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{blurType === "mosaic" && shouldShowFreehandBlurFill && (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
...shapeMaskStyle,
|
||||
backgroundColor: blurOverlayColor,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{blurType === "mosaic" && (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
...shapeMaskStyle,
|
||||
backgroundImage: `linear-gradient(${mosaicGridOverlayColor} 1px, transparent 1px), linear-gradient(90deg, ${mosaicGridOverlayColor} 1px, transparent 1px)`,
|
||||
backgroundSize: `${blockSize}px ${blockSize}px`,
|
||||
mixBlendMode: "screen",
|
||||
opacity: 0.35,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isSelected && shape !== "freehand" && (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none border-2 border-[#34B27B]/80"
|
||||
style={{ borderRadius: shapeBorderRadius }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && shape === "freehand" && freehandPath && (
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
>
|
||||
<path
|
||||
d={freehandPath}
|
||||
fill="none"
|
||||
stroke="#34B27B"
|
||||
strokeWidth="0.55"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{currentPointerPoint && (
|
||||
<circle
|
||||
cx={currentPointerPoint.x}
|
||||
cy={currentPointerPoint.y}
|
||||
r="0.6"
|
||||
fill="#34B27B"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
{isFreehandSelected && (
|
||||
<div
|
||||
className="absolute inset-0 cursor-crosshair"
|
||||
onPointerDown={handleFreehandPointerDown}
|
||||
onPointerMove={handleFreehandPointerMove}
|
||||
onPointerUp={finishFreehandPointer}
|
||||
onPointerCancel={finishFreehandPointer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -125,7 +507,19 @@ export function AnnotationOverlay({
|
||||
onDragStart={() => {
|
||||
isDraggingRef.current = true;
|
||||
}}
|
||||
onDrag={(_e, d) => {
|
||||
setLiveRect((prev) => ({
|
||||
...prev,
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
}));
|
||||
}}
|
||||
onDragStop={(_e, d) => {
|
||||
setLiveRect((prev) => ({
|
||||
...prev,
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
}));
|
||||
const xPercent = (d.x / containerWidth) * 100;
|
||||
const yPercent = (d.y / containerHeight) * 100;
|
||||
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
|
||||
@@ -135,7 +529,21 @@ export function AnnotationOverlay({
|
||||
isDraggingRef.current = false;
|
||||
}, 100);
|
||||
}}
|
||||
onResize={(_e, _direction, ref, _delta, position) => {
|
||||
setLiveRect({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: ref.offsetWidth,
|
||||
height: ref.offsetHeight,
|
||||
});
|
||||
}}
|
||||
onResizeStop={(_e, _direction, ref, _delta, position) => {
|
||||
setLiveRect({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: ref.offsetWidth,
|
||||
height: ref.offsetHeight,
|
||||
});
|
||||
const xPercent = (position.x / containerWidth) * 100;
|
||||
const yPercent = (position.y / containerHeight) * 100;
|
||||
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
|
||||
@@ -149,18 +557,23 @@ export function AnnotationOverlay({
|
||||
}}
|
||||
bounds="parent"
|
||||
className={cn(
|
||||
"cursor-move transition-all",
|
||||
isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
|
||||
"cursor-move",
|
||||
isSelected &&
|
||||
annotation.type !== "blur" &&
|
||||
"ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
|
||||
)}
|
||||
style={{
|
||||
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
|
||||
pointerEvents: isSelected ? "auto" : "none",
|
||||
border: isSelected ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
|
||||
backgroundColor: isSelected ? "rgba(52, 178, 123, 0.1)" : "transparent",
|
||||
boxShadow: isSelected ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
|
||||
border:
|
||||
isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
|
||||
backgroundColor:
|
||||
isSelected && annotation.type !== "blur" ? "rgba(52, 178, 123, 0.1)" : "transparent",
|
||||
boxShadow:
|
||||
isSelected && annotation.type !== "blur" ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
|
||||
}}
|
||||
enableResizing={isSelected}
|
||||
disableDragging={!isSelected}
|
||||
enableResizing={isSelected && !isSelectedFreehandBlur}
|
||||
disableDragging={!isSelected || isSelectedFreehandBlur}
|
||||
resizeHandleStyles={{
|
||||
topLeft: {
|
||||
width: "12px",
|
||||
@@ -206,11 +619,13 @@ export function AnnotationOverlay({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-full rounded-lg",
|
||||
"w-full h-full",
|
||||
annotation.type !== "blur" && "rounded-lg",
|
||||
annotation.type === "text" && "bg-transparent",
|
||||
annotation.type === "image" && "bg-transparent",
|
||||
annotation.type === "figure" && "bg-transparent",
|
||||
isSelected && "shadow-lg",
|
||||
annotation.type === "blur" && "bg-transparent",
|
||||
isSelected && annotation.type !== "blur" && "shadow-lg",
|
||||
)}
|
||||
>
|
||||
{renderContent()}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AlignRight,
|
||||
Bold,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Image as ImageIcon,
|
||||
Info,
|
||||
Italic,
|
||||
@@ -32,7 +33,12 @@ import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AddCustomFontDialog } from "./AddCustomFontDialog";
|
||||
import { getArrowComponent } from "./ArrowSvgs";
|
||||
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type AnnotationType,
|
||||
type ArrowDirection,
|
||||
type FigureData,
|
||||
} from "./types";
|
||||
|
||||
interface AnnotationSettingsPanelProps {
|
||||
annotation: AnnotationRegion;
|
||||
@@ -40,6 +46,7 @@ interface AnnotationSettingsPanelProps {
|
||||
onTypeChange: (type: AnnotationType) => void;
|
||||
onStyleChange: (style: Partial<AnnotationRegion["style"]>) => void;
|
||||
onFigureDataChange?: (figureData: FigureData) => void;
|
||||
onDuplicate?: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
@@ -62,6 +69,7 @@ export function AnnotationSettingsPanel({
|
||||
onTypeChange,
|
||||
onStyleChange,
|
||||
onFigureDataChange,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: AnnotationSettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
@@ -597,15 +605,28 @@ export function AnnotationSettingsPanel({
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Button
|
||||
onClick={onDelete}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t("annotation.deleteAnnotation")}
|
||||
</Button>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
onClick={() => onDuplicate?.()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!onDuplicate}
|
||||
className="w-full gap-2 bg-white/5 text-slate-200 border border-white/10 hover:bg-white/10 hover:border-white/20 transition-all"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
Duplicate
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onDelete}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t("annotation.deleteAnnotation")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-2 text-slate-300">
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { Info, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { getBlurOverlayColor } from "@/lib/blurEffects";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurColor,
|
||||
type BlurData,
|
||||
type BlurShape,
|
||||
DEFAULT_BLUR_BLOCK_SIZE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
MAX_BLUR_BLOCK_SIZE,
|
||||
MAX_BLUR_INTENSITY,
|
||||
MIN_BLUR_BLOCK_SIZE,
|
||||
MIN_BLUR_INTENSITY,
|
||||
} from "./types";
|
||||
|
||||
interface BlurSettingsPanelProps {
|
||||
blurRegion: AnnotationRegion;
|
||||
onBlurDataChange: (blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function BlurSettingsPanel({
|
||||
blurRegion,
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
onDelete,
|
||||
}: BlurSettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
|
||||
const blurShapeOptions: Array<{ value: BlurShape; labelKey: string }> = [
|
||||
{ value: "rectangle", labelKey: "blurShapeRectangle" },
|
||||
{ value: "oval", labelKey: "blurShapeOval" },
|
||||
];
|
||||
const blurColorOptions: Array<{ value: BlurColor; labelKey: string }> = [
|
||||
{ value: "white", labelKey: "blurColorWhite" },
|
||||
{ value: "black", labelKey: "blurColorBlack" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-slate-200">{t("annotation.blurShape")}</span>
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
|
||||
{t("annotation.active")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{blurShapeOptions.map((shape) => {
|
||||
const activeShape = blurRegion.blurData?.shape || DEFAULT_BLUR_DATA.shape;
|
||||
const isActive = activeShape === shape.value;
|
||||
return (
|
||||
<button
|
||||
key={shape.value}
|
||||
onClick={() => {
|
||||
const nextBlurData: BlurData = {
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
shape: shape.value,
|
||||
};
|
||||
onBlurDataChange(nextBlurData);
|
||||
requestAnimationFrame(() => {
|
||||
onBlurDataCommit?.();
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"h-16 rounded-lg border flex flex-col items-center justify-center transition-all p-2 gap-1",
|
||||
isActive
|
||||
? "bg-[#34B27B] border-[#34B27B]"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
|
||||
)}
|
||||
>
|
||||
{shape.value === "rectangle" && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-5 border-2 rounded-sm",
|
||||
isActive ? "border-white" : "border-slate-400",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{shape.value === "oval" && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-8 h-5 border-2 rounded-full",
|
||||
isActive ? "border-white" : "border-slate-400",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="text-[10px] leading-none">
|
||||
{t(`annotation.${shape.labelKey}`)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="text-xs font-medium text-slate-300 mb-2 block">
|
||||
{t("annotation.blurType")}
|
||||
</label>
|
||||
<Select
|
||||
value={blurRegion.blurData?.type ?? DEFAULT_BLUR_DATA.type}
|
||||
onValueChange={(value) => {
|
||||
const nextBlurData: BlurData = {
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
type: value === "mosaic" ? "mosaic" : "blur",
|
||||
};
|
||||
onBlurDataChange(nextBlurData);
|
||||
requestAnimationFrame(() => {
|
||||
onBlurDataCommit?.();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
|
||||
<SelectItem value="blur">{t("annotation.blurTypeBlur")}</SelectItem>
|
||||
<SelectItem value="mosaic">{t("annotation.blurTypeMosaic")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="text-xs font-medium text-slate-300 mb-2 block">
|
||||
{t("annotation.blurColor")}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{blurColorOptions.map((option) => {
|
||||
const activeColor = blurRegion.blurData?.color ?? DEFAULT_BLUR_DATA.color;
|
||||
const isActive = activeColor === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
const nextBlurData: BlurData = {
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
color: option.value,
|
||||
};
|
||||
onBlurDataChange(nextBlurData);
|
||||
requestAnimationFrame(() => {
|
||||
onBlurDataCommit?.();
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"h-10 rounded-lg border flex items-center gap-2 px-3 transition-all",
|
||||
isActive
|
||||
? "bg-[#34B27B] border-[#34B27B]"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full border border-white/20"
|
||||
style={{
|
||||
backgroundColor: getBlurOverlayColor({
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
color: option.value,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-slate-200">
|
||||
{t(`annotation.${option.labelKey}`)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-slate-300">
|
||||
{blurRegion.blurData?.type === "mosaic"
|
||||
? t("annotation.mosaicBlockSize")
|
||||
: t("annotation.blurIntensity")}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
{Math.round(
|
||||
blurRegion.blurData?.type === "mosaic"
|
||||
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
|
||||
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
|
||||
)}
|
||||
px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[
|
||||
blurRegion.blurData?.type === "mosaic"
|
||||
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
|
||||
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
|
||||
]}
|
||||
onValueChange={(values) => {
|
||||
onBlurDataChange({
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
...(blurRegion.blurData?.type === "mosaic"
|
||||
? { blockSize: values[0] }
|
||||
: { intensity: values[0] }),
|
||||
});
|
||||
}}
|
||||
onValueCommit={() => onBlurDataCommit?.()}
|
||||
min={blurRegion.blurData?.type === "mosaic" ? MIN_BLUR_BLOCK_SIZE : MIN_BLUR_INTENSITY}
|
||||
max={blurRegion.blurData?.type === "mosaic" ? MAX_BLUR_BLOCK_SIZE : MAX_BLUR_INTENSITY}
|
||||
step={1}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={onDelete}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{t("annotation.deleteAnnotation")}
|
||||
</Button>
|
||||
|
||||
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-2 text-slate-300">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
|
||||
</div>
|
||||
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
|
||||
<li>{t("annotation.tipMovePlayhead")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,20 +42,86 @@ import { cn } from "@/lib/utils";
|
||||
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import { getTestId } from "@/utils/getTestId";
|
||||
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
||||
import { BlurSettingsPanel } from "./BlurSettingsPanel";
|
||||
import { CropControl } from "./CropControl";
|
||||
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
AnnotationType,
|
||||
BlurData,
|
||||
CropRegion,
|
||||
FigureData,
|
||||
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 +198,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[];
|
||||
@@ -140,7 +210,13 @@ interface SettingsPanelProps {
|
||||
onAnnotationTypeChange?: (id: string, type: AnnotationType) => void;
|
||||
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion["style"]>) => void;
|
||||
onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void;
|
||||
onAnnotationDuplicate?: (id: string) => void;
|
||||
onAnnotationDelete?: (id: string) => void;
|
||||
selectedBlurId?: string | null;
|
||||
blurRegions?: AnnotationRegion[];
|
||||
onBlurDataChange?: (id: string, blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
onBlurDelete?: (id: string) => void;
|
||||
selectedSpeedId?: string | null;
|
||||
selectedSpeedValue?: PlaybackSpeed | null;
|
||||
onSpeedChange?: (speed: PlaybackSpeed) => void;
|
||||
@@ -150,6 +226,12 @@ interface SettingsPanelProps {
|
||||
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
|
||||
webcamMaskShape?: import("./types").WebcamMaskShape;
|
||||
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
|
||||
selectedZoomInDuration?: number;
|
||||
selectedZoomOutDuration?: number;
|
||||
onZoomDurationChange?: (zoomIn: number, zoomOut: number) => void;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
|
||||
onWebcamSizePresetCommit?: () => void;
|
||||
}
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -163,6 +245,13 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
|
||||
{ depth: 6, label: "5×" },
|
||||
];
|
||||
|
||||
const ZOOM_SPEED_OPTIONS = [
|
||||
{ label: "Instant", zoomIn: 0, zoomOut: 0 },
|
||||
{ label: "Fast", zoomIn: 500, zoomOut: 350 },
|
||||
{ label: "Smooth", zoomIn: 1522, zoomOut: 1015 },
|
||||
{ label: "Lazy", zoomIn: 3000, zoomOut: 2000 },
|
||||
];
|
||||
|
||||
export function SettingsPanel({
|
||||
selected,
|
||||
onWallpaperChange,
|
||||
@@ -213,7 +302,13 @@ export function SettingsPanel({
|
||||
onAnnotationTypeChange,
|
||||
onAnnotationStyleChange,
|
||||
onAnnotationFigureDataChange,
|
||||
onAnnotationDuplicate,
|
||||
onAnnotationDelete,
|
||||
selectedBlurId,
|
||||
blurRegions = [],
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
onBlurDelete,
|
||||
selectedSpeedId,
|
||||
selectedSpeedValue,
|
||||
onSpeedChange,
|
||||
@@ -223,6 +318,12 @@ export function SettingsPanel({
|
||||
onWebcamLayoutPresetChange,
|
||||
webcamMaskShape = "rectangle",
|
||||
onWebcamMaskShapeChange,
|
||||
selectedZoomInDuration,
|
||||
selectedZoomOutDuration,
|
||||
onZoomDurationChange,
|
||||
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
onWebcamSizePresetChange,
|
||||
onWebcamSizePresetCommit,
|
||||
}: SettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
|
||||
@@ -268,6 +369,7 @@ export function SettingsPanel({
|
||||
const cropSnapshotRef = useRef<CropRegion | null>(null);
|
||||
const [cropAspectLocked, setCropAspectLocked] = useState(false);
|
||||
const [cropAspectRatio, setCropAspectRatio] = useState("");
|
||||
const isPortraitCanvas = isPortraitAspectRatio(aspectRatio);
|
||||
|
||||
const videoWidth = videoElement?.videoWidth || 1920;
|
||||
const videoHeight = videoElement?.videoHeight || 1080;
|
||||
@@ -446,6 +548,9 @@ export function SettingsPanel({
|
||||
const selectedAnnotation = selectedAnnotationId
|
||||
? annotationRegions.find((a) => a.id === selectedAnnotationId)
|
||||
: null;
|
||||
const selectedBlur = selectedBlurId
|
||||
? blurRegions.find((region) => region.id === selectedBlurId)
|
||||
: null;
|
||||
|
||||
// If an annotation is selected, show annotation settings instead
|
||||
if (
|
||||
@@ -466,11 +571,25 @@ export function SettingsPanel({
|
||||
? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData)
|
||||
: undefined
|
||||
}
|
||||
onDuplicate={
|
||||
onAnnotationDuplicate ? () => onAnnotationDuplicate(selectedAnnotation.id) : undefined
|
||||
}
|
||||
onDelete={() => onAnnotationDelete(selectedAnnotation.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedBlur && onBlurDataChange && onBlurDelete) {
|
||||
return (
|
||||
<BlurSettingsPanel
|
||||
blurRegion={selectedBlur}
|
||||
onBlurDataChange={(blurData) => onBlurDataChange(selectedBlur.id, blurData)}
|
||||
onBlurDataCommit={onBlurDataCommit}
|
||||
onDelete={() => onBlurDelete(selectedBlur.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl flex flex-col shadow-xl h-full overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 pb-0">
|
||||
@@ -547,6 +666,39 @@ export function SettingsPanel({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{zoomEnabled && (
|
||||
<div className="mt-3">
|
||||
<span className="text-sm font-medium text-slate-200 mb-2 block">
|
||||
{t("zoom.speed.title") || "Zoom Speed"}
|
||||
</span>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{ZOOM_SPEED_OPTIONS.map((opt) => {
|
||||
const isActive =
|
||||
selectedZoomInDuration !== undefined &&
|
||||
selectedZoomOutDuration !== undefined &&
|
||||
Math.round(selectedZoomInDuration) === Math.round(opt.zoomIn) &&
|
||||
Math.round(selectedZoomOutDuration) === Math.round(opt.zoomOut);
|
||||
return (
|
||||
<Button
|
||||
key={opt.label}
|
||||
type="button"
|
||||
onClick={() => onZoomDurationChange?.(opt.zoomIn, opt.zoomOut)}
|
||||
className={cn(
|
||||
"h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all",
|
||||
"duration-200 ease-out cursor-pointer",
|
||||
isActive
|
||||
? "border-[#34B27B] bg-[#34B27B] text-white shadow-[#34B27B]/20"
|
||||
: "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200",
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] font-semibold">{opt.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{zoomEnabled && (
|
||||
<Button
|
||||
onClick={handleDeleteClick}
|
||||
@@ -584,7 +736,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 +761,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>
|
||||
)}
|
||||
@@ -656,15 +831,17 @@ export function SettingsPanel({
|
||||
<SelectValue placeholder={t("layout.selectPreset")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WEBCAM_LAYOUT_PRESETS.filter(
|
||||
(preset) =>
|
||||
preset.value === "picture-in-picture" ||
|
||||
isPortraitAspectRatio(aspectRatio),
|
||||
).map((preset) => (
|
||||
{WEBCAM_LAYOUT_PRESETS.filter((preset) => {
|
||||
if (preset.value === "picture-in-picture") return true;
|
||||
if (preset.value === "vertical-stack") return isPortraitCanvas;
|
||||
return !isPortraitCanvas;
|
||||
}).map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value} className="text-xs">
|
||||
{preset.value === "picture-in-picture"
|
||||
? t("layout.pictureInPicture")
|
||||
: t("layout.verticalStack")}
|
||||
: preset.value === "vertical-stack"
|
||||
? t("layout.verticalStack")
|
||||
: t("layout.dualFrame")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -751,6 +928,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>
|
||||
)}
|
||||
@@ -879,7 +1077,7 @@ export function SettingsPanel({
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-3">
|
||||
<Tabs defaultValue="image" className="w-full">
|
||||
<TabsList className="mb-2 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-7 rounded-lg">
|
||||
<TabsList className="mb-2 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 rounded-lg">
|
||||
<TabsTrigger
|
||||
value="image"
|
||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 text-[10px] py-1 rounded-md transition-all"
|
||||
@@ -1016,7 +1214,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,13 +1,21 @@
|
||||
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";
|
||||
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
import { getLocaleName } from "@/i18n/loader";
|
||||
import { type Locale } from "@/i18n/config";
|
||||
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
|
||||
import {
|
||||
calculateOutputDimensions,
|
||||
type ExportFormat,
|
||||
@@ -46,11 +54,13 @@ import { SettingsPanel } from "./SettingsPanel";
|
||||
import TimelineEditor from "./timeline/TimelineEditor";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
type CursorTelemetryPoint,
|
||||
clampFocusToDepth,
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
@@ -64,6 +74,7 @@ import {
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
|
||||
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./videoPlayback/constants";
|
||||
|
||||
export default function VideoEditor() {
|
||||
const {
|
||||
@@ -90,6 +101,7 @@ export default function VideoEditor() {
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
} = editorState;
|
||||
|
||||
@@ -113,10 +125,12 @@ export default function VideoEditor() {
|
||||
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
|
||||
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
|
||||
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
|
||||
const [selectedBlurId, setSelectedBlurId] = useState<string | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
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);
|
||||
@@ -141,12 +155,22 @@ export default function VideoEditor() {
|
||||
const { shortcuts, isMac } = useShortcuts();
|
||||
const t = useScopedT("editor");
|
||||
const ts = useScopedT("settings");
|
||||
const availableLocales = getAvailableLocales();
|
||||
const { locale, setLocale } = useI18n();
|
||||
|
||||
const nextAnnotationIdRef = useRef(1);
|
||||
const nextAnnotationZIndexRef = useRef(1);
|
||||
const exporterRef = useRef<VideoExporter | null>(null);
|
||||
|
||||
const annotationOnlyRegions = useMemo(
|
||||
() => annotationRegions.filter((region) => region.type !== "blur"),
|
||||
[annotationRegions],
|
||||
);
|
||||
const blurRegions = useMemo(
|
||||
() => annotationRegions.filter((region) => region.type === "blur"),
|
||||
[annotationRegions],
|
||||
);
|
||||
|
||||
const currentProjectMedia = useMemo<ProjectMedia | null>(() => {
|
||||
const screenVideoPath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
|
||||
if (!screenVideoPath) {
|
||||
@@ -206,6 +230,7 @@ export default function VideoEditor() {
|
||||
aspectRatio: normalizedEditor.aspectRatio,
|
||||
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
|
||||
webcamMaskShape: normalizedEditor.webcamMaskShape,
|
||||
webcamSizePreset: normalizedEditor.webcamSizePreset,
|
||||
webcamPosition: normalizedEditor.webcamPosition,
|
||||
});
|
||||
setExportQuality(normalizedEditor.exportQuality);
|
||||
@@ -218,6 +243,7 @@ export default function VideoEditor() {
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
|
||||
nextZoomIdRef.current = deriveNextId(
|
||||
"zoom",
|
||||
@@ -416,6 +442,7 @@ export default function VideoEditor() {
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
@@ -479,6 +506,7 @@ export default function VideoEditor() {
|
||||
gifSizePreset,
|
||||
videoPath,
|
||||
t,
|
||||
webcamSizePreset,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -501,6 +529,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();
|
||||
|
||||
@@ -602,7 +640,11 @@ export default function VideoEditor() {
|
||||
|
||||
const handleSelectZoom = useCallback((id: string | null) => {
|
||||
setSelectedZoomId(id);
|
||||
if (id) setSelectedTrimId(null);
|
||||
if (id) {
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectTrim = useCallback((id: string | null) => {
|
||||
@@ -610,6 +652,7 @@ export default function VideoEditor() {
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -618,6 +661,17 @@ export default function VideoEditor() {
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectBlur = useCallback((id: string | null) => {
|
||||
setSelectedBlurId(id);
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedSpeedId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -635,6 +689,7 @@ export default function VideoEditor() {
|
||||
setSelectedZoomId(id);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -653,6 +708,7 @@ export default function VideoEditor() {
|
||||
setSelectedZoomId(id);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -669,6 +725,7 @@ export default function VideoEditor() {
|
||||
setSelectedTrimId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -678,7 +735,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 +752,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 +782,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 +808,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 +820,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);
|
||||
}
|
||||
@@ -763,6 +836,7 @@ export default function VideoEditor() {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -775,11 +849,14 @@ 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);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -840,10 +917,54 @@ 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);
|
||||
setSelectedBlurId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
|
||||
const handleBlurAdded = useCallback(
|
||||
(span: Span) => {
|
||||
const id = `annotation-${nextAnnotationIdRef.current++}`;
|
||||
const zIndex = nextAnnotationZIndexRef.current++;
|
||||
const newRegion: AnnotationRegion = {
|
||||
id,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
type: "blur",
|
||||
content: "",
|
||||
position: { ...DEFAULT_ANNOTATION_POSITION },
|
||||
size: { ...DEFAULT_ANNOTATION_SIZE },
|
||||
style: { ...DEFAULT_ANNOTATION_STYLE },
|
||||
zIndex,
|
||||
blurData: { ...DEFAULT_BLUR_DATA },
|
||||
};
|
||||
pushState((prev) => ({
|
||||
annotationRegions: [...prev.annotationRegions, newRegion],
|
||||
}));
|
||||
setSelectedBlurId(id);
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedSpeedId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
|
||||
const handleZoomDurationChange = useCallback(
|
||||
(id: string, zoomIn: number, zoomOut: number) => {
|
||||
pushState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, zoomInDurationMs: zoomIn, zoomOutDurationMs: zoomOut }
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
@@ -853,7 +974,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,
|
||||
),
|
||||
}));
|
||||
@@ -861,6 +986,33 @@ export default function VideoEditor() {
|
||||
[pushState],
|
||||
);
|
||||
|
||||
const handleAnnotationDuplicate = useCallback(
|
||||
(id: string) => {
|
||||
const duplicateId = `annotation-${nextAnnotationIdRef.current++}`;
|
||||
const duplicateZIndex = nextAnnotationZIndexRef.current++;
|
||||
pushState((prev) => {
|
||||
const source = prev.annotationRegions.find((region) => region.id === id);
|
||||
if (!source) return {};
|
||||
|
||||
const duplicate: AnnotationRegion = {
|
||||
...source,
|
||||
id: duplicateId,
|
||||
zIndex: duplicateZIndex,
|
||||
position: { x: source.position.x + 4, y: source.position.y + 4 },
|
||||
size: { ...source.size },
|
||||
style: { ...source.style },
|
||||
figureData: source.figureData ? { ...source.figureData } : undefined,
|
||||
};
|
||||
|
||||
return { annotationRegions: [...prev.annotationRegions, duplicate] };
|
||||
});
|
||||
setSelectedAnnotationId(duplicateId);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
|
||||
const handleAnnotationDelete = useCallback(
|
||||
(id: string) => {
|
||||
pushState((prev) => ({
|
||||
@@ -869,8 +1021,11 @@ export default function VideoEditor() {
|
||||
if (selectedAnnotationId === id) {
|
||||
setSelectedAnnotationId(null);
|
||||
}
|
||||
if (selectedBlurId === id) {
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
},
|
||||
[selectedAnnotationId, pushState],
|
||||
[selectedAnnotationId, selectedBlurId, pushState],
|
||||
);
|
||||
|
||||
const handleAnnotationContentChange = useCallback(
|
||||
@@ -905,12 +1060,26 @@ export default function VideoEditor() {
|
||||
if (!region.figureData) {
|
||||
updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
|
||||
}
|
||||
} else if (type === "blur") {
|
||||
updatedRegion.content = "";
|
||||
if (!region.blurData) {
|
||||
updatedRegion.blurData = { ...DEFAULT_BLUR_DATA };
|
||||
}
|
||||
}
|
||||
return updatedRegion;
|
||||
}),
|
||||
}));
|
||||
|
||||
if (type === "blur" && selectedAnnotationId === id) {
|
||||
setSelectedAnnotationId(null);
|
||||
setSelectedBlurId(id);
|
||||
setSelectedSpeedId(null);
|
||||
} else if (type !== "blur" && selectedBlurId === id) {
|
||||
setSelectedBlurId(null);
|
||||
setSelectedAnnotationId(id);
|
||||
}
|
||||
},
|
||||
[pushState],
|
||||
[pushState, selectedAnnotationId, selectedBlurId],
|
||||
);
|
||||
|
||||
const handleAnnotationStyleChange = useCallback(
|
||||
@@ -935,6 +1104,51 @@ export default function VideoEditor() {
|
||||
[pushState],
|
||||
);
|
||||
|
||||
const handleBlurDataPreviewChange = useCallback(
|
||||
(id: string, blurData: BlurData) => {
|
||||
updateState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) =>
|
||||
region.id === id
|
||||
? {
|
||||
...region,
|
||||
blurData,
|
||||
// Freehand drawing area is the full video surface.
|
||||
...(blurData.shape === "freehand"
|
||||
? {
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 100 },
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[updateState],
|
||||
);
|
||||
|
||||
const handleBlurDataPanelChange = useCallback(
|
||||
(id: string, blurData: BlurData) => {
|
||||
pushState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) =>
|
||||
region.id === id
|
||||
? {
|
||||
...region,
|
||||
blurData,
|
||||
...(blurData.shape === "freehand"
|
||||
? {
|
||||
position: { x: 0, y: 0 },
|
||||
size: { width: 100, height: 100 },
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
|
||||
const handleAnnotationPositionChange = useCallback(
|
||||
(id: string, position: { x: number; y: number }) => {
|
||||
pushState((prev) => ({
|
||||
@@ -1048,11 +1262,14 @@ export default function VideoEditor() {
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedAnnotationId &&
|
||||
!annotationRegions.some((region) => region.id === selectedAnnotationId)
|
||||
!annotationOnlyRegions.some((region) => region.id === selectedAnnotationId)
|
||||
) {
|
||||
setSelectedAnnotationId(null);
|
||||
}
|
||||
}, [selectedAnnotationId, annotationRegions]);
|
||||
if (selectedBlurId && !blurRegions.some((region) => region.id === selectedBlurId)) {
|
||||
setSelectedBlurId(null);
|
||||
}
|
||||
}, [selectedAnnotationId, selectedBlurId, annotationOnlyRegions, blurRegions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) {
|
||||
@@ -1174,6 +1391,7 @@ export default function VideoEditor() {
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
@@ -1307,6 +1525,7 @@ export default function VideoEditor() {
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
@@ -1377,6 +1596,7 @@ export default function VideoEditor() {
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
handleExportSaved,
|
||||
@@ -1482,6 +1702,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}
|
||||
@@ -1500,13 +1748,21 @@ export default function VideoEditor() {
|
||||
className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1"
|
||||
style={{ color: "inherit" }}
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((loc) => (
|
||||
{availableLocales.map((loc) => (
|
||||
<option key={loc} value={loc} className="bg-[#09090b] text-white">
|
||||
{getLocaleName(loc)}
|
||||
</option>
|
||||
))}
|
||||
</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 +1819,7 @@ export default function VideoEditor() {
|
||||
webcamVideoPath={webcamVideoPath || undefined}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
webcamPosition={webcamPosition}
|
||||
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
|
||||
onWebcamPositionDragEnd={commitState}
|
||||
@@ -1587,11 +1844,18 @@ export default function VideoEditor() {
|
||||
cropRegion={cropRegion}
|
||||
trimRegions={trimRegions}
|
||||
speedRegions={speedRegions}
|
||||
annotationRegions={annotationRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
onAnnotationPositionChange={handleAnnotationPositionChange}
|
||||
onAnnotationSizeChange={handleAnnotationSizeChange}
|
||||
blurRegions={blurRegions}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
onBlurPositionChange={handleAnnotationPositionChange}
|
||||
onBlurSizeChange={handleAnnotationSizeChange}
|
||||
onBlurDataChange={handleBlurDataPreviewChange}
|
||||
onBlurDataCommit={commitState}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
/>
|
||||
</div>
|
||||
@@ -1629,6 +1893,7 @@ export default function VideoEditor() {
|
||||
onZoomAdded={handleZoomAdded}
|
||||
onZoomSuggested={handleZoomSuggested}
|
||||
onZoomSpanChange={handleZoomSpanChange}
|
||||
onZoomDurationChange={handleZoomDurationChange}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
selectedZoomId={selectedZoomId}
|
||||
onSelectZoom={handleSelectZoom}
|
||||
@@ -1644,18 +1909,25 @@ export default function VideoEditor() {
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
onSelectSpeed={handleSelectSpeed}
|
||||
annotationRegions={annotationRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationSpanChange={handleAnnotationSpanChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
blurRegions={blurRegions}
|
||||
onBlurAdded={handleBlurAdded}
|
||||
onBlurSpanChange={handleAnnotationSpanChange}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
onSelectBlur={handleSelectBlur}
|
||||
aspectRatio={aspectRatio}
|
||||
onAspectRatioChange={(ar) =>
|
||||
pushState({
|
||||
aspectRatio: ar,
|
||||
webcamLayoutPreset:
|
||||
!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack"
|
||||
(isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") ||
|
||||
(!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack")
|
||||
? "picture-in-picture"
|
||||
: webcamLayoutPreset,
|
||||
})
|
||||
@@ -1708,11 +1980,14 @@ export default function VideoEditor() {
|
||||
onWebcamLayoutPresetChange={(preset) =>
|
||||
pushState({
|
||||
webcamLayoutPreset: preset,
|
||||
webcamPosition: preset === "vertical-stack" ? null : webcamPosition,
|
||||
webcamPosition: preset === "picture-in-picture" ? webcamPosition : null,
|
||||
})
|
||||
}
|
||||
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}
|
||||
@@ -1739,12 +2014,18 @@ export default function VideoEditor() {
|
||||
)}
|
||||
onExport={handleOpenExportDialog}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
annotationRegions={annotationRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
onAnnotationContentChange={handleAnnotationContentChange}
|
||||
onAnnotationTypeChange={handleAnnotationTypeChange}
|
||||
onAnnotationStyleChange={handleAnnotationStyleChange}
|
||||
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
|
||||
onAnnotationDuplicate={handleAnnotationDuplicate}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedBlurId={selectedBlurId}
|
||||
blurRegions={blurRegions}
|
||||
onBlurDataChange={handleBlurDataPanelChange}
|
||||
onBlurDataCommit={commitState}
|
||||
onBlurDelete={handleAnnotationDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
selectedSpeedValue={
|
||||
selectedSpeedId
|
||||
@@ -1755,6 +2036,21 @@ export default function VideoEditor() {
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
unsavedExport={unsavedExport}
|
||||
onSaveUnsavedExport={handleSaveUnsavedExport}
|
||||
selectedZoomInDuration={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.zoomInDurationMs ??
|
||||
Math.round(ZOOM_IN_TRANSITION_WINDOW_MS))
|
||||
: undefined
|
||||
}
|
||||
selectedZoomOutDuration={
|
||||
selectedZoomId
|
||||
? (zoomRegions.find((z) => z.id === selectedZoomId)?.zoomOutDurationMs ??
|
||||
Math.round(TRANSITION_WINDOW_MS))
|
||||
: undefined
|
||||
}
|
||||
onZoomDurationChange={(zoomIn, zoomOut) =>
|
||||
selectedZoomId && handleZoomDurationChange(selectedZoomId, zoomIn, zoomOut)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type Size,
|
||||
type StyledRenderRect,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamSizePreset,
|
||||
} from "@/lib/compositeLayout";
|
||||
import { getCssClipPath } from "@/lib/webcamMaskShapes";
|
||||
import {
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
import { AnnotationOverlay } from "./AnnotationOverlay";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
ZOOM_DEPTH_SCALES,
|
||||
@@ -69,6 +71,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;
|
||||
@@ -99,6 +102,13 @@ interface VideoPlaybackProps {
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
|
||||
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
|
||||
blurRegions?: AnnotationRegion[];
|
||||
selectedBlurId?: string | null;
|
||||
onSelectBlur?: (id: string | null) => void;
|
||||
onBlurPositionChange?: (id: string, position: { x: number; y: number }) => void;
|
||||
onBlurSizeChange?: (id: string, size: { width: number; height: number }) => void;
|
||||
onBlurDataChange?: (id: string, blurData: BlurData) => void;
|
||||
onBlurDataCommit?: () => void;
|
||||
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
|
||||
}
|
||||
|
||||
@@ -119,6 +129,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
webcamVideoPath,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
onWebcamPositionChange,
|
||||
onWebcamPositionDragEnd,
|
||||
@@ -149,6 +160,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
onSelectAnnotation,
|
||||
onAnnotationPositionChange,
|
||||
onAnnotationSizeChange,
|
||||
blurRegions = [],
|
||||
selectedBlurId,
|
||||
onSelectBlur,
|
||||
onBlurPositionChange,
|
||||
onBlurSizeChange,
|
||||
onBlurDataChange,
|
||||
onBlurDataCommit,
|
||||
cursorTelemetry = [],
|
||||
},
|
||||
ref,
|
||||
@@ -163,11 +181,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const timeUpdateAnimationRef = useRef<number | null>(null);
|
||||
const [pixiReady, setPixiReady] = useState(false);
|
||||
const [videoReady, setVideoReady] = useState(false);
|
||||
const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 });
|
||||
const [overlayElement, setOverlayElement] = useState<HTMLDivElement | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerSize, setContainerSize] = useState({
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
|
||||
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
|
||||
const [webcamLayout, setWebcamLayout] = useState<StyledRenderRect | null>(null);
|
||||
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
|
||||
@@ -290,6 +307,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
padding,
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
});
|
||||
@@ -321,6 +339,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
padding,
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
]);
|
||||
@@ -329,6 +348,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
layoutVideoContentRef.current = layoutVideoContent;
|
||||
}, [layoutVideoContent]);
|
||||
|
||||
const setOverlayRefs = useCallback((node: HTMLDivElement | null) => {
|
||||
overlayRef.current = node;
|
||||
setOverlayElement(node);
|
||||
}, []);
|
||||
|
||||
const selectedZoom = useMemo(() => {
|
||||
if (!selectedZoomId) return null;
|
||||
return zoomRegions.find((region) => region.id === selectedZoomId) ?? null;
|
||||
@@ -345,7 +369,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
if (!vid) return;
|
||||
try {
|
||||
allowPlaybackRef.current = true;
|
||||
await vid.play();
|
||||
await vid.play().catch((err) => console.log("PLAY ERROR:", err));
|
||||
} catch (error) {
|
||||
allowPlaybackRef.current = false;
|
||||
throw error;
|
||||
@@ -519,83 +543,22 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
useEffect(() => {
|
||||
if (!pixiReady || !videoReady) return;
|
||||
|
||||
const app = appRef.current;
|
||||
const cameraContainer = cameraContainerRef.current;
|
||||
const video = videoRef.current;
|
||||
const el = overlayRef.current;
|
||||
if (!el) return;
|
||||
|
||||
if (!app || !cameraContainer || !video) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const { width, height } = entries[0].contentRect;
|
||||
|
||||
const tickerWasStarted = app.ticker?.started || false;
|
||||
if (tickerWasStarted && app.ticker) {
|
||||
app.ticker.stop();
|
||||
}
|
||||
|
||||
const wasPlaying = !video.paused;
|
||||
if (wasPlaying) {
|
||||
video.pause();
|
||||
}
|
||||
|
||||
animationStateRef.current = {
|
||||
scale: 1,
|
||||
focusX: DEFAULT_FOCUS.cx,
|
||||
focusY: DEFAULT_FOCUS.cy,
|
||||
progress: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
appliedScale: 1,
|
||||
};
|
||||
|
||||
// Reset motion blur state for clean transitions
|
||||
motionBlurStateRef.current = createMotionBlurState();
|
||||
|
||||
if (blurFilterRef.current) {
|
||||
blurFilterRef.current.blur = 0;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const container = cameraContainerRef.current;
|
||||
const videoStage = videoContainerRef.current;
|
||||
const sprite = videoSpriteRef.current;
|
||||
const currentApp = appRef.current;
|
||||
if (!container || !videoStage || !sprite || !currentApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.scale.set(1);
|
||||
container.position.set(0, 0);
|
||||
videoStage.scale.set(1);
|
||||
videoStage.position.set(0, 0);
|
||||
sprite.scale.set(1);
|
||||
sprite.position.set(0, 0);
|
||||
|
||||
layoutVideoContent();
|
||||
|
||||
applyZoomTransform({
|
||||
cameraContainer: container,
|
||||
blurFilter: blurFilterRef.current,
|
||||
stageSize: stageSizeRef.current,
|
||||
baseMask: baseMaskRef.current,
|
||||
zoomScale: 1,
|
||||
focusX: DEFAULT_FOCUS.cx,
|
||||
focusY: DEFAULT_FOCUS.cy,
|
||||
motionIntensity: 0,
|
||||
isPlaying: false,
|
||||
motionBlurAmount: motionBlurAmountRef.current,
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const finalApp = appRef.current;
|
||||
if (wasPlaying && video) {
|
||||
video.play().catch(() => {
|
||||
// Ignore autoplay restoration failures.
|
||||
});
|
||||
}
|
||||
if (tickerWasStarted && finalApp?.ticker) {
|
||||
finalApp.ticker.start();
|
||||
}
|
||||
setOverlaySize({
|
||||
width,
|
||||
height,
|
||||
});
|
||||
});
|
||||
}, [pixiReady, videoReady, layoutVideoContent]);
|
||||
|
||||
observer.observe(el);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [pixiReady, videoReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pixiReady || !videoReady) return;
|
||||
@@ -622,7 +585,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]);
|
||||
|
||||
useEffect(() => {
|
||||
const overlayEl = overlayRef.current;
|
||||
if (!pixiReady || !videoReady) return;
|
||||
const overlayEl = overlayElement;
|
||||
if (!overlayEl) return;
|
||||
if (!selectedZoom) {
|
||||
overlayEl.style.cursor = "default";
|
||||
@@ -631,7 +595,34 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
overlayEl.style.cursor = isPlaying ? "not-allowed" : "grab";
|
||||
overlayEl.style.pointerEvents = isPlaying ? "none" : "auto";
|
||||
}, [selectedZoom, isPlaying]);
|
||||
}, [selectedZoom, isPlaying, pixiReady, videoReady, overlayElement]);
|
||||
|
||||
useEffect(() => {
|
||||
const overlayEl = overlayElement;
|
||||
if (!overlayEl) return;
|
||||
|
||||
const updateOverlaySize = () => {
|
||||
const width = overlayEl.clientWidth || 800;
|
||||
const height = overlayEl.clientHeight || 600;
|
||||
setOverlaySize((prev) => {
|
||||
if (prev.width === width && prev.height === height) return prev;
|
||||
return { width, height };
|
||||
});
|
||||
};
|
||||
|
||||
updateOverlaySize();
|
||||
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateOverlaySize();
|
||||
});
|
||||
observer.observe(overlayEl);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
window.addEventListener("resize", updateOverlaySize);
|
||||
return () => window.removeEventListener("resize", updateOverlaySize);
|
||||
}, [overlayElement]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -864,22 +855,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
};
|
||||
|
||||
const ticker = () => {
|
||||
const bm = baseMaskRef.current;
|
||||
const ss = stageSizeRef.current;
|
||||
const viewportRatio =
|
||||
bm.width > 0 && bm.height > 0
|
||||
? {
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1135,21 +1116,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
webcamVideo.currentTime = 0;
|
||||
}, [webcamVideoPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!overlayRef.current) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
setContainerSize({
|
||||
width: overlayRef.current!.clientWidth,
|
||||
height: overlayRef.current!.clientHeight,
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(overlayRef.current);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
@@ -1301,9 +1267,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
{/* Only render overlay after PIXI and video are fully initialized */}
|
||||
{pixiReady && videoReady && (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
ref={setOverlayRefs}
|
||||
className="absolute inset-0 select-none"
|
||||
style={{ pointerEvents: "none", zIndex: 30 }}
|
||||
style={{ pointerEvents: "auto", zIndex: 30 }}
|
||||
onPointerDown={handleOverlayPointerDown}
|
||||
onPointerMove={handleOverlayPointerMove}
|
||||
onPointerUp={handleOverlayPointerUp}
|
||||
@@ -1315,53 +1281,116 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
style={{ display: "none", pointerEvents: "none" }}
|
||||
/>
|
||||
{(() => {
|
||||
const filtered = (annotationRegions || []).filter((annotation) => {
|
||||
const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
|
||||
if (typeof annotation.startMs !== "number" || typeof annotation.endMs !== "number")
|
||||
return false;
|
||||
|
||||
if (annotation.id === selectedAnnotationId) return true;
|
||||
|
||||
const timeMs = Math.round(currentTime * 1000);
|
||||
return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
|
||||
return timeMs >= annotation.startMs && timeMs < annotation.endMs;
|
||||
});
|
||||
|
||||
// Sort by z-index (lowest to highest) so higher z-index renders on top
|
||||
const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
|
||||
const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
|
||||
if (typeof blurRegion.startMs !== "number" || typeof blurRegion.endMs !== "number")
|
||||
return false;
|
||||
|
||||
if (blurRegion.id === selectedBlurId) return true;
|
||||
|
||||
const timeMs = Math.round(currentTime * 1000);
|
||||
return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs;
|
||||
});
|
||||
|
||||
const sorted = [
|
||||
...filteredAnnotations.map((annotation) => ({
|
||||
kind: "annotation" as const,
|
||||
region: annotation,
|
||||
})),
|
||||
...filteredBlurRegions.map((blurRegion) => ({
|
||||
kind: "blur" as const,
|
||||
region: blurRegion,
|
||||
})),
|
||||
].sort((a, b) => a.region.zIndex - b.region.zIndex);
|
||||
const previewSnapshotCanvas = (() => {
|
||||
const app = appRef.current;
|
||||
if (!app?.renderer?.extract) return null;
|
||||
try {
|
||||
return app.renderer.extract.canvas(app.stage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
// Handle click-through cycling: when clicking same annotation, cycle to next
|
||||
const handleAnnotationClick = (clickedId: string) => {
|
||||
if (!onSelectAnnotation) return;
|
||||
|
||||
// If clicking on already selected annotation and there are multiple overlapping
|
||||
if (clickedId === selectedAnnotationId && sorted.length > 1) {
|
||||
if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
|
||||
// Find current index and cycle to next
|
||||
const currentIndex = sorted.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % sorted.length;
|
||||
onSelectAnnotation(sorted[nextIndex].id);
|
||||
const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
|
||||
onSelectAnnotation(filteredAnnotations[nextIndex].id);
|
||||
} else {
|
||||
// First click or clicking different annotation
|
||||
onSelectAnnotation(clickedId);
|
||||
}
|
||||
};
|
||||
|
||||
return sorted.map((annotation) => {
|
||||
const containerWidth = containerSize.width;
|
||||
const containerHeight = containerSize.height;
|
||||
return (
|
||||
<AnnotationOverlay
|
||||
key={annotation.id}
|
||||
annotation={annotation}
|
||||
isSelected={annotation.id === selectedAnnotationId}
|
||||
containerWidth={containerWidth}
|
||||
containerHeight={containerHeight}
|
||||
onPositionChange={(id, position) => onAnnotationPositionChange?.(id, position)}
|
||||
onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)}
|
||||
onClick={handleAnnotationClick}
|
||||
zIndex={annotation.zIndex}
|
||||
isSelectedBoost={annotation.id === selectedAnnotationId}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const handleBlurClick = (clickedId: string) => {
|
||||
if (!onSelectBlur) return;
|
||||
|
||||
if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
|
||||
const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
|
||||
const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
|
||||
onSelectBlur(filteredBlurRegions[nextIndex].id);
|
||||
} else {
|
||||
onSelectBlur(clickedId);
|
||||
}
|
||||
};
|
||||
|
||||
return sorted.map((item) => (
|
||||
<AnnotationOverlay
|
||||
key={
|
||||
item.kind === "blur"
|
||||
? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.type ?? "blur"}-${item.region.blurData?.shape ?? "rectangle"}-${item.region.blurData?.color ?? "white"}-${Math.round(item.region.blurData?.blockSize ?? 0)}-${Math.round(item.region.blurData?.intensity ?? 0)}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
|
||||
: `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
|
||||
}
|
||||
annotation={item.region}
|
||||
isSelected={
|
||||
item.kind === "blur"
|
||||
? item.region.id === selectedBlurId
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
containerWidth={overlaySize.width}
|
||||
containerHeight={overlaySize.height}
|
||||
onPositionChange={(id, position) =>
|
||||
item.kind === "blur"
|
||||
? onBlurPositionChange?.(id, position)
|
||||
: onAnnotationPositionChange?.(id, position)
|
||||
}
|
||||
onSizeChange={(id, size) =>
|
||||
item.kind === "blur"
|
||||
? onBlurSizeChange?.(id, size)
|
||||
: onAnnotationSizeChange?.(id, size)
|
||||
}
|
||||
onBlurDataChange={
|
||||
item.kind === "blur"
|
||||
? (id, blurData) => onBlurDataChange?.(id, blurData)
|
||||
: undefined
|
||||
}
|
||||
onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
|
||||
onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
|
||||
zIndex={item.region.zIndex}
|
||||
isSelectedBoost={
|
||||
item.kind === "blur"
|
||||
? item.region.id === selectedBlurId
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
previewSourceCanvas={previewSnapshotCanvas}
|
||||
previewFrameVersion={Math.round(currentTime * 1000)}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -44,6 +44,7 @@ describe("projectPersistence media compatibility", () => {
|
||||
aspectRatio: "16:9",
|
||||
webcamLayoutPreset: "picture-in-picture",
|
||||
webcamMaskShape: "circle",
|
||||
webcamPosition: null,
|
||||
exportQuality: "good",
|
||||
exportFormat: "mp4",
|
||||
gifFrameRate: 15,
|
||||
@@ -66,6 +67,99 @@ describe("projectPersistence media compatibility", () => {
|
||||
normalizeProjectEditor({ webcamMaskShape: "not-a-real-shape" as never }).webcamMaskShape,
|
||||
).toBe("rectangle");
|
||||
});
|
||||
|
||||
it("normalizes blur region type and mosaic block size safely", () => {
|
||||
const editor = normalizeProjectEditor({
|
||||
annotationRegions: [
|
||||
{
|
||||
id: "annotation-1",
|
||||
startMs: 0,
|
||||
endMs: 500,
|
||||
type: "blur",
|
||||
content: "",
|
||||
position: { x: 10, y: 10 },
|
||||
size: { width: 20, height: 20 },
|
||||
style: {
|
||||
color: "#fff",
|
||||
backgroundColor: "transparent",
|
||||
fontSize: 32,
|
||||
fontFamily: "Inter",
|
||||
fontWeight: "bold",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
textAlign: "center",
|
||||
},
|
||||
zIndex: 1,
|
||||
blurData: {
|
||||
type: "mosaic",
|
||||
shape: "rectangle",
|
||||
color: "black",
|
||||
intensity: 999,
|
||||
blockSize: 999,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "annotation-2",
|
||||
startMs: 0,
|
||||
endMs: 500,
|
||||
type: "blur",
|
||||
content: "",
|
||||
position: { x: 10, y: 10 },
|
||||
size: { width: 20, height: 20 },
|
||||
style: {
|
||||
color: "#fff",
|
||||
backgroundColor: "transparent",
|
||||
fontSize: 32,
|
||||
fontFamily: "Inter",
|
||||
fontWeight: "bold",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
textAlign: "center",
|
||||
},
|
||||
zIndex: 2,
|
||||
blurData: {
|
||||
type: "invalid" as never,
|
||||
shape: "rectangle",
|
||||
color: "invalid" as never,
|
||||
intensity: 10,
|
||||
blockSize: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(editor.annotationRegions[0].blurData?.type).toBe("mosaic");
|
||||
expect(editor.annotationRegions[0].blurData?.color).toBe("black");
|
||||
expect(editor.annotationRegions[0].blurData?.intensity).toBe(40);
|
||||
expect(editor.annotationRegions[0].blurData?.blockSize).toBe(48);
|
||||
expect(editor.annotationRegions[1].blurData?.type).toBe("blur");
|
||||
expect(editor.annotationRegions[1].blurData?.color).toBe("white");
|
||||
expect(editor.annotationRegions[1].blurData?.blockSize).toBe(4);
|
||||
});
|
||||
|
||||
it("accepts the dual frame webcam layout preset", () => {
|
||||
expect(normalizeProjectEditor({ webcamLayoutPreset: "dual-frame" }).webcamLayoutPreset).toBe(
|
||||
"dual-frame",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back from dual frame to picture in picture for portrait aspect ratios", () => {
|
||||
expect(
|
||||
normalizeProjectEditor({
|
||||
aspectRatio: "9:16",
|
||||
webcamLayoutPreset: "dual-frame",
|
||||
}).webcamLayoutPreset,
|
||||
).toBe("picture-in-picture");
|
||||
});
|
||||
|
||||
it("clears webcamPosition when the normalized preset is not picture in picture", () => {
|
||||
expect(
|
||||
normalizeProjectEditor({
|
||||
webcamLayoutPreset: "dual-frame",
|
||||
webcamPosition: { cx: 0.2, cy: 0.8 },
|
||||
}).webcamPosition,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("creates stable snapshots for identical project state", () => {
|
||||
|
||||
@@ -1,29 +1,44 @@
|
||||
import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects";
|
||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import type { ProjectMedia } from "@/lib/recordingSession";
|
||||
import { normalizeProjectMedia } from "@/lib/recordingSession";
|
||||
import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type CropRegion,
|
||||
clampPlaybackSpeed,
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_BLUR_BLOCK_SIZE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_BLUR_FREEHAND_POINTS,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
MAX_BLUR_BLOCK_SIZE,
|
||||
MAX_BLUR_INTENSITY,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
MIN_BLUR_BLOCK_SIZE,
|
||||
MIN_BLUR_INTENSITY,
|
||||
MIN_PLAYBACK_SPEED,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamMaskShape,
|
||||
type WebcamPosition,
|
||||
type WebcamSizePreset,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
|
||||
const WALLPAPER_COUNT = 18;
|
||||
const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const);
|
||||
|
||||
export const WALLPAPER_PATHS = Array.from(
|
||||
{ length: WALLPAPER_COUNT },
|
||||
@@ -47,6 +62,7 @@ export interface ProjectEditorState {
|
||||
aspectRatio: AspectRatio;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
webcamMaskShape: WebcamMaskShape;
|
||||
webcamSizePreset: WebcamSizePreset;
|
||||
webcamPosition: WebcamPosition | null;
|
||||
exportQuality: ExportQuality;
|
||||
exportFormat: ExportFormat;
|
||||
@@ -66,6 +82,26 @@ function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function computeNormalizedWebcamLayoutPreset(
|
||||
webcamLayoutPreset: Partial<ProjectEditorState>["webcamLayoutPreset"],
|
||||
normalizedAspectRatio: AspectRatio,
|
||||
): WebcamLayoutPreset {
|
||||
switch (webcamLayoutPreset) {
|
||||
case "picture-in-picture":
|
||||
return webcamLayoutPreset;
|
||||
case "vertical-stack":
|
||||
return isPortraitAspectRatio(normalizedAspectRatio)
|
||||
? webcamLayoutPreset
|
||||
: DEFAULT_WEBCAM_LAYOUT_PRESET;
|
||||
case "dual-frame":
|
||||
return isPortraitAspectRatio(normalizedAspectRatio)
|
||||
? DEFAULT_WEBCAM_LAYOUT_PRESET
|
||||
: webcamLayoutPreset;
|
||||
default:
|
||||
return DEFAULT_WEBCAM_LAYOUT_PRESET;
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
@@ -173,6 +209,26 @@ export function resolveProjectMedia(
|
||||
|
||||
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
|
||||
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
|
||||
const normalizedAspectRatio: AspectRatio = validAspectRatios.has(
|
||||
editor.aspectRatio as AspectRatio,
|
||||
)
|
||||
? (editor.aspectRatio as AspectRatio)
|
||||
: "16:9";
|
||||
const normalizedWebcamLayoutPreset = computeNormalizedWebcamLayoutPreset(
|
||||
editor.webcamLayoutPreset,
|
||||
normalizedAspectRatio,
|
||||
);
|
||||
const normalizedWebcamPosition: WebcamPosition | null =
|
||||
normalizedWebcamLayoutPreset === "picture-in-picture" &&
|
||||
editor.webcamPosition &&
|
||||
typeof editor.webcamPosition === "object" &&
|
||||
isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) &&
|
||||
isFiniteNumber((editor.webcamPosition as WebcamPosition).cy)
|
||||
? {
|
||||
cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
|
||||
cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
|
||||
}
|
||||
: DEFAULT_WEBCAM_POSITION;
|
||||
|
||||
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
|
||||
? editor.zoomRegions
|
||||
@@ -223,14 +279,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 {
|
||||
@@ -252,12 +304,22 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
const blurShape =
|
||||
typeof region.blurData?.shape === "string" &&
|
||||
VALID_BLUR_SHAPES.has(region.blurData.shape)
|
||||
? region.blurData.shape
|
||||
: DEFAULT_BLUR_DATA.shape;
|
||||
const blurType = normalizeBlurType(region.blurData?.type);
|
||||
const blurColor = normalizeBlurColor(region.blurData?.color);
|
||||
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
type: region.type === "image" || region.type === "figure" ? region.type : "text",
|
||||
type:
|
||||
region.type === "image" || region.type === "figure" || region.type === "blur"
|
||||
? region.type
|
||||
: "text",
|
||||
content: typeof region.content === "string" ? region.content : "",
|
||||
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
|
||||
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
|
||||
@@ -304,6 +366,42 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
...region.figureData,
|
||||
}
|
||||
: undefined,
|
||||
blurData:
|
||||
region.blurData && typeof region.blurData === "object"
|
||||
? {
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...region.blurData,
|
||||
type: blurType,
|
||||
shape: blurShape,
|
||||
color: blurColor,
|
||||
intensity: isFiniteNumber(region.blurData.intensity)
|
||||
? clamp(region.blurData.intensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY)
|
||||
: DEFAULT_BLUR_INTENSITY,
|
||||
blockSize: isFiniteNumber(region.blurData.blockSize)
|
||||
? clamp(region.blurData.blockSize, MIN_BLUR_BLOCK_SIZE, MAX_BLUR_BLOCK_SIZE)
|
||||
: DEFAULT_BLUR_BLOCK_SIZE,
|
||||
freehandPoints: Array.isArray(region.blurData.freehandPoints)
|
||||
? region.blurData.freehandPoints
|
||||
.filter(
|
||||
(
|
||||
point,
|
||||
): point is {
|
||||
x: number;
|
||||
y: number;
|
||||
} =>
|
||||
Boolean(
|
||||
point &&
|
||||
isFiniteNumber((point as { x?: unknown }).x) &&
|
||||
isFiniteNumber((point as { y?: unknown }).y),
|
||||
),
|
||||
)
|
||||
.map((point) => ({
|
||||
x: clamp(point.x, 0, 100),
|
||||
y: clamp(point.y, 0, 100),
|
||||
}))
|
||||
: DEFAULT_BLUR_FREEHAND_POINTS,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
@@ -349,13 +447,8 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
trimRegions: normalizedTrimRegions,
|
||||
speedRegions: normalizedSpeedRegions,
|
||||
annotationRegions: normalizedAnnotationRegions,
|
||||
aspectRatio:
|
||||
editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
|
||||
webcamLayoutPreset:
|
||||
editor.webcamLayoutPreset === "vertical-stack" ||
|
||||
editor.webcamLayoutPreset === "picture-in-picture"
|
||||
? editor.webcamLayoutPreset
|
||||
: DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
aspectRatio: normalizedAspectRatio,
|
||||
webcamLayoutPreset: normalizedWebcamLayoutPreset,
|
||||
webcamMaskShape:
|
||||
editor.webcamMaskShape === "rectangle" ||
|
||||
editor.webcamMaskShape === "circle" ||
|
||||
@@ -363,16 +456,11 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
editor.webcamMaskShape === "rounded"
|
||||
? editor.webcamMaskShape
|
||||
: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
webcamPosition:
|
||||
editor.webcamPosition &&
|
||||
typeof editor.webcamPosition === "object" &&
|
||||
isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) &&
|
||||
isFiniteNumber((editor.webcamPosition as WebcamPosition).cy)
|
||||
? {
|
||||
cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
|
||||
cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
|
||||
}
|
||||
: DEFAULT_WEBCAM_POSITION,
|
||||
webcamSizePreset:
|
||||
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
|
||||
? Math.max(10, Math.min(50, editor.webcamSizePreset))
|
||||
: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
webcamPosition: normalizedWebcamPosition,
|
||||
exportQuality:
|
||||
editor.exportQuality === "medium" || editor.exportQuality === "source"
|
||||
? editor.exportQuality
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { Span } from "dnd-timeline";
|
||||
import { useItem } from "dnd-timeline";
|
||||
import { useItem, useTimelineContext } from "dnd-timeline";
|
||||
import { Gauge, MessageSquare, Scissors, ZoomIn } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DEFAULT_ZOOM_IN_MS,
|
||||
DEFAULT_ZOOM_OUT_MS,
|
||||
getDurations,
|
||||
} from "../videoPlayback/zoomRegionUtils";
|
||||
import glassStyles from "./ItemGlass.module.css";
|
||||
|
||||
interface ItemProps {
|
||||
@@ -13,8 +18,11 @@ interface ItemProps {
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
zoomDepth?: number;
|
||||
zoomInDurationMs?: number;
|
||||
zoomOutDurationMs?: number;
|
||||
speedValue?: number;
|
||||
variant?: "zoom" | "trim" | "annotation" | "speed";
|
||||
onZoomDurationChange?: (id: string, zoomIn: number, zoomOut: number) => void;
|
||||
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
|
||||
}
|
||||
|
||||
// Map zoom depth to multiplier labels
|
||||
@@ -44,10 +52,14 @@ export default function Item({
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
zoomDepth = 1,
|
||||
zoomInDurationMs,
|
||||
zoomOutDurationMs,
|
||||
speedValue,
|
||||
variant = "zoom",
|
||||
children,
|
||||
onZoomDurationChange,
|
||||
}: ItemProps) {
|
||||
const { pixelsToValue } = useTimelineContext();
|
||||
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
|
||||
id,
|
||||
span,
|
||||
@@ -79,6 +91,16 @@ export default function Item({
|
||||
const MIN_ITEM_PX = 6;
|
||||
const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX };
|
||||
|
||||
const { zoomIn, zoomOut } = useMemo(() => {
|
||||
if (!isZoom) return { zoomIn: 0, zoomOut: 0 };
|
||||
return getDurations({
|
||||
startMs: span.start,
|
||||
endMs: span.end,
|
||||
zoomInDurationMs,
|
||||
zoomOutDurationMs,
|
||||
});
|
||||
}, [isZoom, span.start, span.end, zoomInDurationMs, zoomOutDurationMs]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
@@ -101,6 +123,98 @@ export default function Item({
|
||||
onSelect?.();
|
||||
}}
|
||||
>
|
||||
{isZoom && (
|
||||
<>
|
||||
{/* Transition In Marker */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 bg-white/10 border-r border-white/20 pointer-events-none"
|
||||
style={{
|
||||
width: `${(zoomIn / (span.end - span.start)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
{/* Draggable handle for Transition In */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-2 cursor-col-resize z-20 group-hover:bg-white/5 transition-colors"
|
||||
style={{
|
||||
left: `${(zoomIn / (span.end - span.start)) * 100}%`,
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const target = e.currentTarget;
|
||||
target.setPointerCapture(e.pointerId);
|
||||
|
||||
const startX = e.clientX;
|
||||
const initialZoomIn = zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS;
|
||||
const initialZoomOut = zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS;
|
||||
|
||||
const onPointerMove = (moveEvent: PointerEvent) => {
|
||||
const deltaPx = moveEvent.clientX - startX;
|
||||
const deltaMs = pixelsToValue(deltaPx);
|
||||
const newDuration = Math.max(
|
||||
0,
|
||||
Math.min(initialZoomIn + deltaMs, span.end - span.start - initialZoomOut),
|
||||
);
|
||||
onZoomDurationChange?.(id, newDuration, initialZoomOut);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
target.releasePointerCapture(e.pointerId);
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", onPointerUp);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
}}
|
||||
/>
|
||||
{/* Transition Out Marker */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 right-0 bg-white/10 border-l border-white/20 pointer-events-none"
|
||||
style={{
|
||||
width: `${(zoomOut / (span.end - span.start)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
{/* Draggable handle for Transition Out */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-2 cursor-col-resize z-20 group-hover:bg-white/5 transition-colors"
|
||||
style={{
|
||||
right: `${(zoomOut / (span.end - span.start)) * 100}%`,
|
||||
transform: "translateX(50%)",
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const target = e.currentTarget;
|
||||
target.setPointerCapture(e.pointerId);
|
||||
|
||||
const startX = e.clientX;
|
||||
const initialZoomIn = zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS;
|
||||
const initialZoomOut = zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS;
|
||||
|
||||
const onPointerMove = (moveEvent: PointerEvent) => {
|
||||
const deltaPx = startX - moveEvent.clientX; // Inverted because right-anchored
|
||||
const deltaMs = pixelsToValue(deltaPx);
|
||||
const newDuration = Math.max(
|
||||
0,
|
||||
Math.min(initialZoomOut + deltaMs, span.end - span.start - initialZoomIn),
|
||||
);
|
||||
onZoomDurationChange?.(id, initialZoomIn, newDuration);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
target.releasePointerCapture(e.pointerId);
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", onPointerUp);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
|
||||
style={{
|
||||
|
||||
@@ -44,6 +44,7 @@ import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSugge
|
||||
const ZOOM_ROW_ID = "row-zoom";
|
||||
const TRIM_ROW_ID = "row-trim";
|
||||
const ANNOTATION_ROW_ID = "row-annotation";
|
||||
const BLUR_ROW_ID = "row-blur";
|
||||
const SPEED_ROW_ID = "row-speed";
|
||||
const FALLBACK_RANGE_MS = 1000;
|
||||
const TARGET_MARKER_COUNT = 12;
|
||||
@@ -58,6 +59,7 @@ interface TimelineEditorProps {
|
||||
onZoomAdded: (span: Span) => void;
|
||||
onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
|
||||
onZoomSpanChange: (id: string, span: Span) => void;
|
||||
onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void;
|
||||
onZoomDelete: (id: string) => void;
|
||||
selectedZoomId: string | null;
|
||||
onSelectZoom: (id: string | null) => void;
|
||||
@@ -73,6 +75,12 @@ interface TimelineEditorProps {
|
||||
onAnnotationDelete?: (id: string) => void;
|
||||
selectedAnnotationId?: string | null;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
blurRegions?: AnnotationRegion[];
|
||||
onBlurAdded?: (span: Span) => void;
|
||||
onBlurSpanChange?: (id: string, span: Span) => void;
|
||||
onBlurDelete?: (id: string) => void;
|
||||
selectedBlurId?: string | null;
|
||||
onSelectBlur?: (id: string | null) => void;
|
||||
speedRegions?: SpeedRegion[];
|
||||
onSpeedAdded?: (span: Span) => void;
|
||||
onSpeedSpanChange?: (id: string, span: Span) => void;
|
||||
@@ -96,7 +104,9 @@ interface TimelineRenderItem {
|
||||
label: string;
|
||||
zoomDepth?: number;
|
||||
speedValue?: number;
|
||||
variant: "zoom" | "trim" | "annotation" | "speed";
|
||||
zoomInDurationMs?: number;
|
||||
zoomOutDurationMs?: number;
|
||||
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
|
||||
}
|
||||
|
||||
const SCALE_CANDIDATES = [
|
||||
@@ -525,11 +535,14 @@ function Timeline({
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectAnnotation,
|
||||
onSelectBlur,
|
||||
onSelectSpeed,
|
||||
selectedZoomId,
|
||||
selectedTrimId,
|
||||
selectedAnnotationId,
|
||||
selectedBlurId,
|
||||
selectedSpeedId,
|
||||
onZoomDurationChange,
|
||||
keyframes = [],
|
||||
}: {
|
||||
items: TimelineRenderItem[];
|
||||
@@ -540,11 +553,14 @@ function Timeline({
|
||||
onSelectZoom?: (id: string | null) => void;
|
||||
onSelectTrim?: (id: string | null) => void;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
onSelectBlur?: (id: string | null) => void;
|
||||
onSelectSpeed?: (id: string | null) => void;
|
||||
selectedZoomId: string | null;
|
||||
selectedTrimId?: string | null;
|
||||
selectedAnnotationId?: string | null;
|
||||
selectedBlurId?: string | null;
|
||||
selectedSpeedId?: string | null;
|
||||
onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void;
|
||||
keyframes?: { id: string; time: number }[];
|
||||
}) {
|
||||
const t = useScopedT("timeline");
|
||||
@@ -568,6 +584,7 @@ function Timeline({
|
||||
onSelectZoom?.(null);
|
||||
onSelectTrim?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectBlur?.(null);
|
||||
onSelectSpeed?.(null);
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
@@ -586,6 +603,7 @@ function Timeline({
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectAnnotation,
|
||||
onSelectBlur,
|
||||
onSelectSpeed,
|
||||
videoDurationMs,
|
||||
sidebarWidth,
|
||||
@@ -637,6 +655,7 @@ function Timeline({
|
||||
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
|
||||
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
|
||||
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
|
||||
const blurItems = items.filter((item) => item.rowId === BLUR_ROW_ID);
|
||||
const speedItems = items.filter((item) => item.rowId === SPEED_ROW_ID);
|
||||
|
||||
return (
|
||||
@@ -668,6 +687,9 @@ function Timeline({
|
||||
isSelected={item.id === selectedZoomId}
|
||||
onSelect={() => onSelectZoom?.(item.id)}
|
||||
zoomDepth={item.zoomDepth}
|
||||
zoomInDurationMs={item.zoomInDurationMs}
|
||||
zoomOutDurationMs={item.zoomOutDurationMs}
|
||||
onZoomDurationChange={onZoomDurationChange}
|
||||
variant="zoom"
|
||||
>
|
||||
{item.label}
|
||||
@@ -711,6 +733,22 @@ function Timeline({
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={BLUR_ROW_ID} isEmpty={blurItems.length === 0} hint={t("hints.pressBlur")}>
|
||||
{blurItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedBlurId}
|
||||
onSelect={() => onSelectBlur?.(item.id)}
|
||||
variant={item.variant}
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint={t("hints.pressSpeed")}>
|
||||
{speedItems.map((item) => (
|
||||
<Item
|
||||
@@ -740,6 +778,7 @@ export default function TimelineEditor({
|
||||
onZoomAdded,
|
||||
onZoomSuggested,
|
||||
onZoomSpanChange,
|
||||
onZoomDurationChange,
|
||||
onZoomDelete,
|
||||
selectedZoomId,
|
||||
onSelectZoom,
|
||||
@@ -755,6 +794,12 @@ export default function TimelineEditor({
|
||||
onAnnotationDelete,
|
||||
selectedAnnotationId,
|
||||
onSelectAnnotation,
|
||||
blurRegions = [],
|
||||
onBlurAdded,
|
||||
onBlurSpanChange,
|
||||
onBlurDelete,
|
||||
selectedBlurId,
|
||||
onSelectBlur,
|
||||
speedRegions = [],
|
||||
onSpeedAdded,
|
||||
onSpeedSpanChange,
|
||||
@@ -839,6 +884,12 @@ export default function TimelineEditor({
|
||||
onSelectAnnotation(null);
|
||||
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
|
||||
|
||||
const deleteSelectedBlur = useCallback(() => {
|
||||
if (!selectedBlurId || !onBlurDelete || !onSelectBlur) return;
|
||||
onBlurDelete(selectedBlurId);
|
||||
onSelectBlur(null);
|
||||
}, [selectedBlurId, onBlurDelete, onSelectBlur]);
|
||||
|
||||
const deleteSelectedSpeed = useCallback(() => {
|
||||
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
|
||||
onSpeedDelete(selectedSpeedId);
|
||||
@@ -908,9 +959,10 @@ export default function TimelineEditor({
|
||||
const isZoomItem = zoomRegions.some((r) => r.id === excludeId);
|
||||
const isTrimItem = trimRegions.some((r) => r.id === excludeId);
|
||||
const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId);
|
||||
const isBlurItem = blurRegions.some((r) => r.id === excludeId);
|
||||
const isSpeedItem = speedRegions.some((r) => r.id === excludeId);
|
||||
|
||||
if (isAnnotationItem) {
|
||||
if (isAnnotationItem || isBlurItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -937,7 +989,7 @@ export default function TimelineEditor({
|
||||
|
||||
return false;
|
||||
},
|
||||
[zoomRegions, trimRegions, annotationRegions, speedRegions],
|
||||
[zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions],
|
||||
);
|
||||
|
||||
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
|
||||
@@ -1165,6 +1217,21 @@ export default function TimelineEditor({
|
||||
onAnnotationAdded({ start: startPos, end: endPos });
|
||||
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
|
||||
|
||||
const handleAddBlur = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onBlurAdded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
|
||||
if (defaultDuration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
|
||||
const endPos = Math.min(startPos + defaultDuration, totalMs);
|
||||
onBlurAdded({ start: startPos, end: endPos });
|
||||
}, [videoDuration, totalMs, currentTimeMs, onBlurAdded, defaultRegionDurationMs]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
@@ -1183,6 +1250,9 @@ export default function TimelineEditor({
|
||||
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
|
||||
handleAddAnnotation();
|
||||
}
|
||||
if (matchesShortcut(e, keyShortcuts.addBlur, isMac)) {
|
||||
handleAddBlur();
|
||||
}
|
||||
if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
|
||||
handleAddSpeed();
|
||||
}
|
||||
@@ -1223,6 +1293,8 @@ export default function TimelineEditor({
|
||||
deleteSelectedTrim();
|
||||
} else if (selectedAnnotationId) {
|
||||
deleteSelectedAnnotation();
|
||||
} else if (selectedBlurId) {
|
||||
deleteSelectedBlur();
|
||||
} else if (selectedSpeedId) {
|
||||
deleteSelectedSpeed();
|
||||
}
|
||||
@@ -1235,16 +1307,19 @@ export default function TimelineEditor({
|
||||
handleAddZoom,
|
||||
handleAddTrim,
|
||||
handleAddAnnotation,
|
||||
handleAddBlur,
|
||||
handleAddSpeed,
|
||||
deleteSelectedKeyframe,
|
||||
deleteSelectedZoom,
|
||||
deleteSelectedTrim,
|
||||
deleteSelectedAnnotation,
|
||||
deleteSelectedBlur,
|
||||
deleteSelectedSpeed,
|
||||
selectedKeyframeId,
|
||||
selectedZoomId,
|
||||
selectedTrimId,
|
||||
selectedAnnotationId,
|
||||
selectedBlurId,
|
||||
selectedSpeedId,
|
||||
annotationRegions,
|
||||
currentTime,
|
||||
@@ -1271,6 +1346,8 @@ export default function TimelineEditor({
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: t("labels.zoomItem", { index: String(index + 1) }),
|
||||
zoomDepth: region.depth,
|
||||
zoomInDurationMs: region.zoomInDurationMs,
|
||||
zoomOutDurationMs: region.zoomOutDurationMs,
|
||||
variant: "zoom",
|
||||
}));
|
||||
|
||||
@@ -1304,6 +1381,14 @@ export default function TimelineEditor({
|
||||
};
|
||||
});
|
||||
|
||||
const blurs: TimelineRenderItem[] = blurRegions.map((region, index) => ({
|
||||
id: region.id,
|
||||
rowId: BLUR_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: t("labels.blurItem", { index: String(index + 1) }),
|
||||
variant: "blur",
|
||||
}));
|
||||
|
||||
const speeds: TimelineRenderItem[] = speedRegions.map((region, index) => ({
|
||||
id: region.id,
|
||||
rowId: SPEED_ROW_ID,
|
||||
@@ -1313,8 +1398,8 @@ export default function TimelineEditor({
|
||||
variant: "speed",
|
||||
}));
|
||||
|
||||
return [...zooms, ...trims, ...annotations, ...speeds];
|
||||
}, [zoomRegions, trimRegions, annotationRegions, speedRegions, t]);
|
||||
return [...zooms, ...trims, ...annotations, ...blurs, ...speeds];
|
||||
}, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]);
|
||||
|
||||
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
|
||||
const allRegionSpans = useMemo(() => {
|
||||
@@ -1335,6 +1420,8 @@ export default function TimelineEditor({
|
||||
onSpeedSpanChange?.(id, span);
|
||||
} else if (annotationRegions.some((r) => r.id === id)) {
|
||||
onAnnotationSpanChange?.(id, span);
|
||||
} else if (blurRegions.some((r) => r.id === id)) {
|
||||
onBlurSpanChange?.(id, span);
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -1342,10 +1429,12 @@ export default function TimelineEditor({
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
annotationRegions,
|
||||
blurRegions,
|
||||
onZoomSpanChange,
|
||||
onTrimSpanChange,
|
||||
onSpeedSpanChange,
|
||||
onAnnotationSpanChange,
|
||||
onBlurSpanChange,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1403,6 +1492,25 @@ export default function TimelineEditor({
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddBlur}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#7dd3fc] hover:bg-[#7dd3fc]/10 transition-all"
|
||||
title={t("buttons.addBlur")}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="8" cy="12" r="3" />
|
||||
<circle cx="16" cy="12" r="3" />
|
||||
<path d="M6 6h12M6 18h12" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddSpeed}
|
||||
variant="ghost"
|
||||
@@ -1489,11 +1597,14 @@ export default function TimelineEditor({
|
||||
onSelectZoom={onSelectZoom}
|
||||
onSelectTrim={onSelectTrim}
|
||||
onSelectAnnotation={onSelectAnnotation}
|
||||
onSelectBlur={onSelectBlur}
|
||||
onSelectSpeed={onSelectSpeed}
|
||||
selectedZoomId={selectedZoomId}
|
||||
selectedTrimId={selectedTrimId}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
selectedBlurId={selectedBlurId}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
onZoomDurationChange={onZoomDurationChange}
|
||||
keyframes={keyframes}
|
||||
/>
|
||||
</TimelineWrapper>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -29,6 +33,8 @@ export interface ZoomRegion {
|
||||
depth: ZoomDepth;
|
||||
focus: ZoomFocus;
|
||||
focusMode?: ZoomFocusMode;
|
||||
zoomInDurationMs?: number;
|
||||
zoomOutDurationMs?: number;
|
||||
}
|
||||
|
||||
export interface CursorTelemetryPoint {
|
||||
@@ -43,7 +49,7 @@ export interface TrimRegion {
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
export type AnnotationType = "text" | "image" | "figure";
|
||||
export type AnnotationType = "text" | "image" | "figure" | "blur";
|
||||
|
||||
export type ArrowDirection =
|
||||
| "up"
|
||||
@@ -61,6 +67,27 @@ export interface FigureData {
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
export type BlurShape = "rectangle" | "oval" | "freehand";
|
||||
export type BlurType = "blur" | "mosaic";
|
||||
export type BlurColor = "white" | "black";
|
||||
|
||||
export const MIN_BLUR_INTENSITY = 2;
|
||||
export const MAX_BLUR_INTENSITY = 40;
|
||||
export const DEFAULT_BLUR_INTENSITY = 12;
|
||||
export const MIN_BLUR_BLOCK_SIZE = 4;
|
||||
export const MAX_BLUR_BLOCK_SIZE = 48;
|
||||
export const DEFAULT_BLUR_BLOCK_SIZE = 12;
|
||||
|
||||
export interface BlurData {
|
||||
type: BlurType;
|
||||
shape: BlurShape;
|
||||
color: BlurColor;
|
||||
intensity: number;
|
||||
blockSize: number;
|
||||
// Points are normalized (0-100) within the annotation bounds.
|
||||
freehandPoints?: Array<{ x: number; y: number }>;
|
||||
}
|
||||
|
||||
export interface AnnotationPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -95,6 +122,7 @@ export interface AnnotationRegion {
|
||||
style: AnnotationTextStyle;
|
||||
zIndex: number;
|
||||
figureData?: FigureData;
|
||||
blurData?: BlurData;
|
||||
}
|
||||
|
||||
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
|
||||
@@ -124,6 +152,27 @@ export const DEFAULT_FIGURE_DATA: FigureData = {
|
||||
strokeWidth: 4,
|
||||
};
|
||||
|
||||
export const DEFAULT_BLUR_FREEHAND_POINTS: Array<{ x: number; y: number }> = [
|
||||
{ x: 10, y: 30 },
|
||||
{ x: 25, y: 10 },
|
||||
{ x: 55, y: 8 },
|
||||
{ x: 82, y: 20 },
|
||||
{ x: 90, y: 45 },
|
||||
{ x: 78, y: 72 },
|
||||
{ x: 52, y: 90 },
|
||||
{ x: 22, y: 84 },
|
||||
{ x: 8, y: 58 },
|
||||
];
|
||||
|
||||
export const DEFAULT_BLUR_DATA: BlurData = {
|
||||
type: "blur",
|
||||
shape: "rectangle",
|
||||
color: "white",
|
||||
intensity: DEFAULT_BLUR_INTENSITY,
|
||||
blockSize: DEFAULT_BLUR_BLOCK_SIZE,
|
||||
freehandPoints: DEFAULT_BLUR_FREEHAND_POINTS,
|
||||
};
|
||||
|
||||
export interface CropRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -138,7 +187,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 +213,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,
|
||||
});
|
||||
@@ -136,7 +140,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
screenRect.y,
|
||||
screenRect.width,
|
||||
screenRect.height,
|
||||
compositeLayout.screenCover ? 0 : borderRadius,
|
||||
compositeLayout.screenBorderRadius ?? (compositeLayout.screenCover ? 0 : borderRadius),
|
||||
);
|
||||
maskGraphics.fill({ color: 0xffffff });
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
|
||||
|
||||
const CHAINED_ZOOM_PAN_GAP_MS = 1500;
|
||||
const CONNECTED_ZOOM_PAN_DURATION_MS = 1000;
|
||||
const ZOOM_IN_OVERLAP_MS = 500;
|
||||
|
||||
type DominantRegionOptions = {
|
||||
connectZooms?: boolean;
|
||||
@@ -38,26 +37,49 @@ function easeConnectedPan(value: number) {
|
||||
return cubicBezier(0.1, 0.0, 0.2, 1.0, value);
|
||||
}
|
||||
|
||||
export function computeRegionStrength(region: ZoomRegion, timeMs: number) {
|
||||
const zoomInEnd = region.startMs + ZOOM_IN_OVERLAP_MS;
|
||||
const leadInStart = zoomInEnd - ZOOM_IN_TRANSITION_WINDOW_MS;
|
||||
const leadOutEnd = region.endMs + TRANSITION_WINDOW_MS;
|
||||
export const DEFAULT_ZOOM_OUT_MS = TRANSITION_WINDOW_MS;
|
||||
export const DEFAULT_ZOOM_IN_MS = ZOOM_IN_TRANSITION_WINDOW_MS;
|
||||
|
||||
if (timeMs < leadInStart || timeMs > leadOutEnd) {
|
||||
export function getDurations(region: {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
zoomInDurationMs?: number;
|
||||
zoomOutDurationMs?: number;
|
||||
}) {
|
||||
let zoomIn = region.zoomInDurationMs ?? DEFAULT_ZOOM_IN_MS;
|
||||
let zoomOut = region.zoomOutDurationMs ?? DEFAULT_ZOOM_OUT_MS;
|
||||
|
||||
const duration = region.endMs - region.startMs;
|
||||
if (zoomIn + zoomOut > duration) {
|
||||
const scale = duration / (zoomIn + zoomOut);
|
||||
zoomIn *= scale;
|
||||
zoomOut *= scale;
|
||||
}
|
||||
|
||||
return { zoomIn, zoomOut };
|
||||
}
|
||||
|
||||
export function computeRegionStrength(region: ZoomRegion, timeMs: number) {
|
||||
const { zoomIn, zoomOut } = getDurations(region);
|
||||
|
||||
if (timeMs < region.startMs || timeMs > region.endMs) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (timeMs < zoomInEnd) {
|
||||
const progress = (timeMs - leadInStart) / ZOOM_IN_TRANSITION_WINDOW_MS;
|
||||
// Zooming in
|
||||
if (timeMs < region.startMs + zoomIn) {
|
||||
const progress = Math.max(0, Math.min(1, (timeMs - region.startMs) / zoomIn));
|
||||
return easeOutScreenStudio(progress);
|
||||
}
|
||||
|
||||
if (timeMs <= region.endMs) {
|
||||
return 1;
|
||||
// Zooming out
|
||||
if (timeMs > region.endMs - zoomOut) {
|
||||
const progress = Math.max(0, Math.min(1, (region.endMs - timeMs) / zoomOut));
|
||||
return easeOutScreenStudio(progress);
|
||||
}
|
||||
|
||||
const progress = clamp01((timeMs - region.endMs) / TRANSITION_WINDOW_MS);
|
||||
return 1 - easeOutScreenStudio(progress);
|
||||
// Full zoom
|
||||
return 1;
|
||||
}
|
||||
|
||||
function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus {
|
||||
|
||||
@@ -90,8 +90,10 @@ export function computeZoomTransform({
|
||||
}
|
||||
|
||||
const progress = Math.min(1, Math.max(0, zoomProgress));
|
||||
const focusStagePxX = baseMask.x + focusX * baseMask.width;
|
||||
const focusStagePxY = baseMask.y + focusY * baseMask.height;
|
||||
// Focus coordinates are stage-normalized (0-1 of full canvas),
|
||||
// so map directly to stage pixels, not through baseMask.
|
||||
const focusStagePxX = focusX * stageSize.width;
|
||||
const focusStagePxY = focusY * stageSize.height;
|
||||
const stageCenterX = stageSize.width / 2;
|
||||
const stageCenterY = stageSize.height / 2;
|
||||
const scale = 1 + (zoomScale - 1) * progress;
|
||||
@@ -128,8 +130,8 @@ export function computeFocusFromTransform({
|
||||
const focusStagePxY = (stageCenterY - y) / zoomScale;
|
||||
|
||||
return {
|
||||
cx: (focusStagePxX - baseMask.x) / baseMask.width,
|
||||
cy: (focusStagePxY - baseMask.y) / baseMask.height,
|
||||
cx: focusStagePxX / stageSize.width,
|
||||
cy: focusStagePxY / stageSize.height,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+112
-10
@@ -5,16 +5,11 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
type I18nNamespace,
|
||||
LOCALE_STORAGE_KEY,
|
||||
type Locale,
|
||||
SUPPORTED_LOCALES,
|
||||
} from "@/i18n/config";
|
||||
import { translate } from "@/i18n/loader";
|
||||
import { DEFAULT_LOCALE, type I18nNamespace, LOCALE_STORAGE_KEY, type Locale } from "@/i18n/config";
|
||||
import { getAvailableLocales, translate } from "@/i18n/loader";
|
||||
|
||||
type TranslateVars = Record<string, string | number>;
|
||||
|
||||
@@ -22,8 +17,14 @@ interface I18nContextValue {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
t: (qualifiedKey: string, vars?: TranslateVars) => string;
|
||||
systemLocaleSuggestion: Locale | null;
|
||||
acceptSystemLocaleSuggestion: () => void;
|
||||
dismissSystemLocaleSuggestion: () => void;
|
||||
resolveSystemLocaleSuggestion: () => void;
|
||||
}
|
||||
|
||||
const SYSTEM_LANGUAGE_PROMPT_SEEN_KEY = "openscreen-system-language-prompt-seen";
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
@@ -41,7 +42,37 @@ export function useScopedT(namespace: I18nNamespace) {
|
||||
}
|
||||
|
||||
function isSupportedLocale(value: string): value is Locale {
|
||||
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
return getAvailableLocales().includes(value);
|
||||
}
|
||||
|
||||
function getSupportedSystemLocale(): Locale | null {
|
||||
if (typeof navigator === "undefined") return null;
|
||||
const availableLocales = getAvailableLocales();
|
||||
|
||||
const candidates =
|
||||
Array.isArray(navigator.languages) && navigator.languages.length > 0
|
||||
? navigator.languages
|
||||
: [navigator.language];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue;
|
||||
if (isSupportedLocale(candidate)) return candidate;
|
||||
|
||||
const exactMatch = availableLocales.find(
|
||||
(locale) => locale.toLowerCase() === candidate.toLowerCase(),
|
||||
);
|
||||
if (exactMatch) return exactMatch;
|
||||
|
||||
const baseLanguage = candidate.split("-")[0]?.toLowerCase();
|
||||
if (!baseLanguage) continue;
|
||||
|
||||
if (baseLanguage === "zh" && availableLocales.includes("zh-CN")) return "zh-CN";
|
||||
|
||||
const baseMatch = availableLocales.find((locale) => locale.toLowerCase() === baseLanguage);
|
||||
if (baseMatch) return baseMatch;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getInitialLocale(): Locale {
|
||||
@@ -56,6 +87,16 @@ function getInitialLocale(): Locale {
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
|
||||
const [systemLocaleSuggestion, setSystemLocaleSuggestion] = useState<Locale | null>(null);
|
||||
const hasRunSystemLocaleCheckRef = useRef(false);
|
||||
|
||||
const markPromptAsHandled = useCallback(() => {
|
||||
try {
|
||||
localStorage.setItem(SYSTEM_LANGUAGE_PROMPT_SEEN_KEY, "1");
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setLocale = useCallback((newLocale: Locale) => {
|
||||
setLocaleState(newLocale);
|
||||
@@ -73,6 +114,48 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
document.documentElement.lang = locale;
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRunSystemLocaleCheckRef.current) return;
|
||||
hasRunSystemLocaleCheckRef.current = true;
|
||||
|
||||
let hasStoredLocale = false;
|
||||
let hasHandledSystemPrompt = false;
|
||||
try {
|
||||
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
hasStoredLocale = Boolean(stored && isSupportedLocale(stored));
|
||||
hasHandledSystemPrompt = localStorage.getItem(SYSTEM_LANGUAGE_PROMPT_SEEN_KEY) === "1";
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
|
||||
if (hasStoredLocale || hasHandledSystemPrompt) return;
|
||||
|
||||
const detectedSystemLocale = getSupportedSystemLocale();
|
||||
if (!detectedSystemLocale || detectedSystemLocale === DEFAULT_LOCALE) {
|
||||
markPromptAsHandled();
|
||||
return;
|
||||
}
|
||||
|
||||
setSystemLocaleSuggestion(detectedSystemLocale);
|
||||
}, [markPromptAsHandled]);
|
||||
|
||||
const acceptSystemLocaleSuggestion = useCallback(() => {
|
||||
if (!systemLocaleSuggestion) return;
|
||||
setLocale(systemLocaleSuggestion);
|
||||
setSystemLocaleSuggestion(null);
|
||||
markPromptAsHandled();
|
||||
}, [markPromptAsHandled, setLocale, systemLocaleSuggestion]);
|
||||
|
||||
const dismissSystemLocaleSuggestion = useCallback(() => {
|
||||
setSystemLocaleSuggestion(null);
|
||||
markPromptAsHandled();
|
||||
}, [markPromptAsHandled]);
|
||||
|
||||
const resolveSystemLocaleSuggestion = useCallback(() => {
|
||||
setSystemLocaleSuggestion(null);
|
||||
markPromptAsHandled();
|
||||
}, [markPromptAsHandled]);
|
||||
|
||||
const t = useCallback(
|
||||
(qualifiedKey: string, vars?: TranslateVars): string => {
|
||||
const dotIndex = qualifiedKey.indexOf(".");
|
||||
@@ -84,7 +167,26 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
[locale],
|
||||
);
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
|
||||
const value = useMemo<I18nContextValue>(
|
||||
() => ({
|
||||
locale,
|
||||
setLocale,
|
||||
t,
|
||||
systemLocaleSuggestion,
|
||||
acceptSystemLocaleSuggestion,
|
||||
dismissSystemLocaleSuggestion,
|
||||
resolveSystemLocaleSuggestion,
|
||||
}),
|
||||
[
|
||||
locale,
|
||||
setLocale,
|
||||
t,
|
||||
systemLocaleSuggestion,
|
||||
acceptSystemLocaleSuggestion,
|
||||
dismissSystemLocaleSuggestion,
|
||||
resolveSystemLocaleSuggestion,
|
||||
],
|
||||
);
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
+268
-35
@@ -110,6 +110,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const allowAutoFinalize = useRef(false);
|
||||
const discardRecordingId = useRef<number | null>(null);
|
||||
const restarting = useRef(false);
|
||||
const countdownRunId = useRef(0);
|
||||
const [countdownActive, setCountdownActive] = useState(false);
|
||||
const webcamReady = useRef(false);
|
||||
const webcamAcquireId = useRef(0);
|
||||
|
||||
const getRecordingDurationMs = useCallback(() => {
|
||||
const segmentDuration =
|
||||
@@ -158,10 +162,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
microphoneStream.current.getTracks().forEach((track) => track.stop());
|
||||
microphoneStream.current = null;
|
||||
}
|
||||
if (webcamStream.current) {
|
||||
webcamStream.current.getTracks().forEach((track) => track.stop());
|
||||
webcamStream.current = null;
|
||||
}
|
||||
if (mixingContext.current) {
|
||||
mixingContext.current.close().catch(() => {
|
||||
// Ignore close errors during recorder teardown.
|
||||
@@ -194,6 +194,85 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
[t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webcamEnabled) return;
|
||||
|
||||
let cancelled = false;
|
||||
let acquiredStream: MediaStream | null = null;
|
||||
const thisAcquireId = ++webcamAcquireId.current;
|
||||
webcamReady.current = false;
|
||||
|
||||
const acquire = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: webcamDeviceId
|
||||
? {
|
||||
deviceId: { exact: webcamDeviceId },
|
||||
width: { ideal: WEBCAM_TARGET_WIDTH },
|
||||
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
||||
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
||||
}
|
||||
: {
|
||||
width: { ideal: WEBCAM_TARGET_WIDTH },
|
||||
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
||||
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
||||
},
|
||||
});
|
||||
|
||||
if (cancelled || thisAcquireId !== webcamAcquireId.current) {
|
||||
stream.getTracks().forEach((track) => {
|
||||
track.onended = null;
|
||||
track.stop();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
acquiredStream = stream;
|
||||
stream.getVideoTracks().forEach((track) => {
|
||||
track.onended = () => {
|
||||
webcamStream.current = null;
|
||||
if (!restarting.current) {
|
||||
setWebcamEnabledState(false);
|
||||
toast.error(t("recording.cameraDisconnected"));
|
||||
}
|
||||
};
|
||||
});
|
||||
webcamStream.current = stream;
|
||||
webcamReady.current = true;
|
||||
} catch (cameraError) {
|
||||
if (!cancelled) {
|
||||
console.warn("Failed to get webcam access:", cameraError);
|
||||
setWebcamEnabledState(false);
|
||||
const isDeviceError =
|
||||
cameraError instanceof DOMException &&
|
||||
[
|
||||
"NotFoundError",
|
||||
"DevicesNotFoundError",
|
||||
"OverconstrainedError",
|
||||
"NotReadableError",
|
||||
].includes(cameraError.name);
|
||||
toast.error(t(isDeviceError ? "recording.cameraNotFound" : "recording.cameraBlocked"));
|
||||
webcamReady.current = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void acquire();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
webcamReady.current = false;
|
||||
if (acquiredStream) {
|
||||
acquiredStream.getTracks().forEach((track) => {
|
||||
track.onended = null;
|
||||
track.stop();
|
||||
});
|
||||
webcamStream.current = null;
|
||||
}
|
||||
};
|
||||
}, [webcamEnabled, webcamDeviceId, t]);
|
||||
|
||||
const finalizeRecording = useCallback(
|
||||
(
|
||||
activeScreenRecorder: RecorderHandle,
|
||||
@@ -334,7 +413,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
|
||||
return () => {
|
||||
const activeRunId = countdownRunId.current;
|
||||
if (cleanup) cleanup();
|
||||
countdownRunId.current += 1;
|
||||
void safeHideCountdownOverlay(activeRunId);
|
||||
allowAutoFinalize.current = false;
|
||||
restarting.current = false;
|
||||
discardRecordingId.current = null;
|
||||
@@ -365,7 +447,117 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
};
|
||||
}, [teardownMedia]);
|
||||
|
||||
const startRecording = async () => {
|
||||
const safeShowCountdownOverlay = async (value: number, runId: number) => {
|
||||
try {
|
||||
await window.electronAPI.showCountdownOverlay(value, runId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("Failed to show countdown overlay:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelCountdown = () => {
|
||||
const activeRunId = countdownRunId.current;
|
||||
countdownRunId.current += 1;
|
||||
setCountdownActive(false);
|
||||
void safeHideCountdownOverlay(activeRunId);
|
||||
};
|
||||
|
||||
const safeSetCountdownOverlayValue = async (value: number, runId: number) => {
|
||||
try {
|
||||
await window.electronAPI.setCountdownOverlayValue(value, runId);
|
||||
} catch (error) {
|
||||
console.warn("Failed to update countdown overlay value:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const safeHideCountdownOverlay = async (runId: number) => {
|
||||
try {
|
||||
await window.electronAPI.hideCountdownOverlay(runId);
|
||||
} catch (error) {
|
||||
console.warn("Failed to hide countdown overlay:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const isCountdownRunActive = (runId?: number) =>
|
||||
runId === undefined || countdownRunId.current === runId;
|
||||
|
||||
const startRecordCountdown = async () => {
|
||||
if (countdownActive || recording) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = countdownRunId.current + 1;
|
||||
countdownRunId.current = runId;
|
||||
setCountdownActive(true);
|
||||
|
||||
let selectedSource: ProcessedDesktopSource | null = null;
|
||||
try {
|
||||
selectedSource = await window.electronAPI.getSelectedSource();
|
||||
} catch (error) {
|
||||
console.warn("Failed to read selected source before countdown:", error);
|
||||
}
|
||||
|
||||
if (!isCountdownRunActive(runId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedSource) {
|
||||
if (countdownRunId.current === runId) {
|
||||
setCountdownActive(false);
|
||||
}
|
||||
alert(t("recording.selectSource"));
|
||||
return;
|
||||
}
|
||||
|
||||
let overlayHiddenBeforeStart = false;
|
||||
try {
|
||||
const values = [3, 2, 1];
|
||||
const overlayShown = await safeShowCountdownOverlay(values[0], runId);
|
||||
|
||||
if (countdownRunId.current !== runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const value of values) {
|
||||
if (countdownRunId.current !== runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (overlayShown && value !== values[0]) {
|
||||
await safeSetCountdownOverlayValue(value, runId);
|
||||
|
||||
if (countdownRunId.current !== runId) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
if (countdownRunId.current !== runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCountdownActive(false);
|
||||
await safeHideCountdownOverlay(runId);
|
||||
overlayHiddenBeforeStart = true;
|
||||
|
||||
if (countdownRunId.current !== runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await startRecording(runId);
|
||||
} finally {
|
||||
if (!overlayHiddenBeforeStart && countdownRunId.current === runId) {
|
||||
setCountdownActive(false);
|
||||
await safeHideCountdownOverlay(runId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = async (countdownRunToken?: number) => {
|
||||
try {
|
||||
const selectedSource = await window.electronAPI.getSelectedSource();
|
||||
if (!selectedSource) {
|
||||
@@ -373,6 +565,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
teardownMedia();
|
||||
return;
|
||||
}
|
||||
|
||||
let screenMediaStream: MediaStream;
|
||||
|
||||
const videoConstraints = {
|
||||
@@ -413,6 +610,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
screenStream.current = screenMediaStream;
|
||||
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
teardownMedia();
|
||||
return;
|
||||
}
|
||||
|
||||
if (microphoneEnabled) {
|
||||
try {
|
||||
microphoneStream.current = await navigator.mediaDevices.getUserMedia({
|
||||
@@ -437,32 +639,35 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
teardownMedia();
|
||||
return;
|
||||
}
|
||||
|
||||
if (webcamEnabled) {
|
||||
try {
|
||||
webcamStream.current = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: webcamDeviceId
|
||||
? {
|
||||
deviceId: { exact: webcamDeviceId },
|
||||
width: { ideal: WEBCAM_TARGET_WIDTH },
|
||||
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
||||
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
||||
}
|
||||
: {
|
||||
width: { ideal: WEBCAM_TARGET_WIDTH },
|
||||
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
||||
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
||||
},
|
||||
if (!webcamReady.current) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
if (webcamReady.current) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}, 5000);
|
||||
});
|
||||
} catch (cameraError) {
|
||||
console.warn("Failed to get webcam access:", cameraError);
|
||||
if (webcamStream.current) {
|
||||
webcamStream.current.getTracks().forEach((track) => track.stop());
|
||||
webcamStream.current = null;
|
||||
}
|
||||
setWebcamEnabledState(false);
|
||||
toast.error(t("recording.cameraDenied"));
|
||||
}
|
||||
if (!webcamStream.current) {
|
||||
webcamAcquireId.current++;
|
||||
setWebcamEnabledState(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
teardownMedia();
|
||||
return;
|
||||
}
|
||||
|
||||
stream.current = new MediaStream();
|
||||
@@ -505,6 +710,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
teardownMedia();
|
||||
return;
|
||||
}
|
||||
|
||||
let {
|
||||
width = DEFAULT_WIDTH,
|
||||
height = DEFAULT_HEIGHT,
|
||||
@@ -524,6 +734,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
);
|
||||
|
||||
const hasAudio = stream.current.getAudioTracks().length > 0;
|
||||
if (!isCountdownRunActive(countdownRunToken)) {
|
||||
teardownMedia();
|
||||
return;
|
||||
}
|
||||
|
||||
screenRecorder.current = createRecorderHandle(stream.current, {
|
||||
mimeType,
|
||||
videoBitsPerSecond,
|
||||
@@ -635,7 +850,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
};
|
||||
|
||||
const toggleRecording = () => {
|
||||
recording ? stopRecording.current() : startRecording();
|
||||
if (recording) {
|
||||
stopRecording.current();
|
||||
return;
|
||||
}
|
||||
|
||||
if (countdownActive) {
|
||||
cancelCountdown();
|
||||
return;
|
||||
}
|
||||
|
||||
void startRecordCountdown();
|
||||
};
|
||||
|
||||
const restartRecording = async () => {
|
||||
@@ -649,7 +874,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
|
||||
restarting.current = true;
|
||||
discardRecordingId.current = activeRecordingId;
|
||||
allowAutoFinalize.current = false;
|
||||
|
||||
const stopPromises = [
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -700,13 +924,22 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
|
||||
const cancelRecording = () => {
|
||||
const activeScreenRecorder = screenRecorder.current;
|
||||
if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return;
|
||||
if (
|
||||
activeScreenRecorder?.recorder.state === "recording" ||
|
||||
activeScreenRecorder?.recorder.state === "paused"
|
||||
) {
|
||||
const activeRecordingId = recordingId.current;
|
||||
discardRecordingId.current = activeRecordingId;
|
||||
allowAutoFinalize.current = false;
|
||||
|
||||
const activeRecordingId = recordingId.current;
|
||||
discardRecordingId.current = activeRecordingId;
|
||||
allowAutoFinalize.current = false;
|
||||
stopRecording.current();
|
||||
return;
|
||||
}
|
||||
|
||||
stopRecording.current();
|
||||
if (countdownActive) {
|
||||
cancelCountdown();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
import enDialogs from "@/i18n/locales/en/dialogs.json";
|
||||
import esDialogs from "@/i18n/locales/es/dialogs.json";
|
||||
import frDialogs from "@/i18n/locales/fr/dialogs.json";
|
||||
import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json";
|
||||
import trDialogs from "@/i18n/locales/tr/dialogs.json";
|
||||
import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json";
|
||||
|
||||
const tutorialHelpKeys = [
|
||||
"triggerLabel",
|
||||
"title",
|
||||
"description",
|
||||
"explanationBefore",
|
||||
"remove",
|
||||
"explanationMiddle",
|
||||
"covered",
|
||||
"explanationAfter",
|
||||
"visualExample",
|
||||
"removed",
|
||||
"kept",
|
||||
"part1",
|
||||
"part2",
|
||||
"part3",
|
||||
"finalVideo",
|
||||
"step1Title",
|
||||
"step1DescriptionBefore",
|
||||
"step1DescriptionAfter",
|
||||
"step2Title",
|
||||
"step2Description",
|
||||
] as const;
|
||||
|
||||
const keysThatMayBeEmpty = new Set<(typeof tutorialHelpKeys)[number]>(["step1DescriptionBefore"]);
|
||||
|
||||
const dialogsByLocale = {
|
||||
en: enDialogs,
|
||||
"zh-CN": zhCNDialogs,
|
||||
es: esDialogs,
|
||||
fr: frDialogs,
|
||||
tr: trDialogs,
|
||||
"ko-KR": koKRDialogs,
|
||||
} satisfies Record<Locale, { tutorial: Record<string, unknown> }>;
|
||||
|
||||
describe("TutorialHelp translations", () => {
|
||||
it("defines every tutorial help key for each supported locale", () => {
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const tutorial = dialogsByLocale[locale].tutorial;
|
||||
|
||||
for (const key of tutorialHelpKeys) {
|
||||
const message = tutorial[key];
|
||||
const label = `${locale} dialogs.tutorial.${key}`;
|
||||
expect(message, label).toEqual(expect.any(String));
|
||||
if (!keysThatMayBeEmpty.has(key)) {
|
||||
expect((message as string).trim().length, label).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -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", "zh-TW", "es", "fr", "tr", "ko-KR"] as const;
|
||||
export const I18N_NAMESPACES = [
|
||||
"common",
|
||||
"dialogs",
|
||||
@@ -10,7 +10,7 @@ export const I18N_NAMESPACES = [
|
||||
"timeline",
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
export type Locale = string;
|
||||
export type I18nNamespace = (typeof I18N_NAMESPACES)[number];
|
||||
|
||||
export const LOCALE_STORAGE_KEY = "openscreen-locale";
|
||||
|
||||
+71
-6
@@ -1,6 +1,10 @@
|
||||
import { DEFAULT_LOCALE, type I18nNamespace, type Locale } from "./config";
|
||||
import { DEFAULT_LOCALE, I18N_NAMESPACES, type I18nNamespace, type Locale } from "./config";
|
||||
|
||||
type MessageMap = Record<string, unknown>;
|
||||
type LocaleValidationError = {
|
||||
locale: string;
|
||||
missingNamespaces: I18nNamespace[];
|
||||
};
|
||||
|
||||
const modules = import.meta.glob("./locales/**/*.json", { eager: true }) as Record<
|
||||
string,
|
||||
@@ -18,6 +22,62 @@ for (const [path, mod] of Object.entries(modules)) {
|
||||
messages[locale][namespace] = mod.default;
|
||||
}
|
||||
|
||||
const REQUIRED_NAMESPACES = new Set<string>(I18N_NAMESPACES);
|
||||
|
||||
const localeValidationErrors: LocaleValidationError[] = Object.keys(messages)
|
||||
.map((locale) => {
|
||||
const localeMessages = messages[locale] ?? {};
|
||||
const missingNamespaces = I18N_NAMESPACES.filter((namespace) => !localeMessages[namespace]);
|
||||
return {
|
||||
locale,
|
||||
missingNamespaces,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.missingNamespaces.length > 0);
|
||||
|
||||
const invalidLocales = new Set(localeValidationErrors.map((entry) => entry.locale));
|
||||
|
||||
const availableLocales = Object.keys(messages)
|
||||
.filter((locale) => REQUIRED_NAMESPACES.size > 0 && hasRequiredNamespaces(messages[locale]))
|
||||
.filter((locale) => !invalidLocales.has(locale))
|
||||
.sort((a, b) => {
|
||||
if (a === DEFAULT_LOCALE) return -1;
|
||||
if (b === DEFAULT_LOCALE) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
if (localeValidationErrors.length > 0) {
|
||||
console.error("[i18n] Incomplete locale folders were excluded:");
|
||||
for (const entry of localeValidationErrors) {
|
||||
console.error(
|
||||
`[i18n] ${entry.locale}: missing ${entry.missingNamespaces.map((ns) => `${ns}.json`).join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hasRequiredNamespaces(localeMessages: Record<string, MessageMap> | undefined): boolean {
|
||||
if (!localeMessages) return false;
|
||||
for (const namespace of REQUIRED_NAMESPACES) {
|
||||
if (!localeMessages[namespace]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isAvailableLocale(locale: string): locale is Locale {
|
||||
return availableLocales.includes(locale);
|
||||
}
|
||||
|
||||
export function getAvailableLocales(): Locale[] {
|
||||
if (availableLocales.length === 0) {
|
||||
return [DEFAULT_LOCALE];
|
||||
}
|
||||
return availableLocales;
|
||||
}
|
||||
|
||||
export function getLocaleValidationErrors(): LocaleValidationError[] {
|
||||
return localeValidationErrors;
|
||||
}
|
||||
|
||||
function getMessageValue(obj: unknown, dotPath: string): string | undefined {
|
||||
const keys = dotPath.split(".");
|
||||
let current: unknown = obj;
|
||||
@@ -34,15 +94,18 @@ function interpolate(str: string, vars?: Record<string, string | number>): strin
|
||||
}
|
||||
|
||||
export function getMessages(locale: Locale, namespace: I18nNamespace): MessageMap {
|
||||
return messages[locale]?.[namespace] ?? {};
|
||||
const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
return messages[resolvedLocale]?.[namespace] ?? {};
|
||||
}
|
||||
|
||||
export function getLocaleName(locale: Locale): string {
|
||||
return getMessageValue(messages[locale]?.common, "locale.name") ?? locale;
|
||||
const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
return getMessageValue(messages[resolvedLocale]?.common, "locale.name") ?? locale;
|
||||
}
|
||||
|
||||
export function getLocaleShort(locale: Locale): string {
|
||||
return getMessageValue(messages[locale]?.common, "locale.short") ?? locale;
|
||||
const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
return getMessageValue(messages[resolvedLocale]?.common, "locale.short") ?? locale;
|
||||
}
|
||||
|
||||
export function translate(
|
||||
@@ -52,8 +115,10 @@ export function translate(
|
||||
vars?: Record<string, string | number>,
|
||||
): string {
|
||||
const value =
|
||||
getMessageValue(messages[locale]?.[namespace], key) ??
|
||||
getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key);
|
||||
getMessageValue(
|
||||
messages[isAvailableLocale(locale) ? locale : DEFAULT_LOCALE]?.[namespace],
|
||||
key,
|
||||
) ?? getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key);
|
||||
|
||||
if (value == null) return `${namespace}.${key}`;
|
||||
return interpolate(value, vars);
|
||||
|
||||
@@ -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",
|
||||
@@ -30,6 +36,8 @@
|
||||
"systemAudioUnavailable": "System audio not available. Recording without system audio.",
|
||||
"microphoneDenied": "Microphone access denied. Recording will continue without audio.",
|
||||
"cameraDenied": "Camera access denied. Recording will continue without webcam.",
|
||||
"cameraDisconnected": "Webcam disconnected.",
|
||||
"cameraNotFound": "Camera not found.",
|
||||
"permissionDenied": "Recording permission denied. Please allow screen recording."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,11 @@
|
||||
"recording": {
|
||||
"selectSource": "Please select a source to record"
|
||||
},
|
||||
"language": "Language"
|
||||
"language": "Language",
|
||||
"systemLanguagePrompt": {
|
||||
"title": "Use your system language?",
|
||||
"description": "We detected {{language}} as your system language. Do you want to switch OpenScreen to {{language}}?",
|
||||
"switch": "Switch to {{language}}",
|
||||
"keepDefault": "Keep current language"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,21 @@
|
||||
"manual": "Manual",
|
||||
"auto": "Auto",
|
||||
"autoDescription": "Camera follows the recorded cursor position"
|
||||
},
|
||||
"speed": {
|
||||
"title": "Zoom Speed",
|
||||
"instant": "Instant",
|
||||
"fast": "Fast",
|
||||
"smooth": "Smooth",
|
||||
"lazy": "Lazy"
|
||||
}
|
||||
},
|
||||
"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 +33,9 @@
|
||||
"selectPreset": "Select preset",
|
||||
"pictureInPicture": "Picture in Picture",
|
||||
"verticalStack": "Vertical Stack",
|
||||
"webcamShape": "Camera Shape"
|
||||
"dualFrame": "Dual Frame",
|
||||
"webcamShape": "Camera Shape",
|
||||
"webcamSize": "Webcam Size"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Video Effects",
|
||||
@@ -98,6 +109,7 @@
|
||||
"typeText": "Text",
|
||||
"typeImage": "Image",
|
||||
"typeArrow": "Arrow",
|
||||
"typeBlur": "Blur",
|
||||
"textContent": "Text Content",
|
||||
"textPlaceholder": "Enter your text...",
|
||||
"fontStyle": "Font Style",
|
||||
@@ -114,6 +126,18 @@
|
||||
"arrowDirection": "Arrow Direction",
|
||||
"strokeWidth": "Stroke Width: {{width}}px",
|
||||
"arrowColor": "Arrow Color",
|
||||
"blurType": "Blur Type",
|
||||
"blurTypeBlur": "Blur",
|
||||
"blurTypeMosaic": "Mosaic Blur",
|
||||
"blurColor": "Blur Color",
|
||||
"blurColorWhite": "White",
|
||||
"blurColorBlack": "Black",
|
||||
"blurShape": "Blur Shape",
|
||||
"blurIntensity": "Blur Intensity",
|
||||
"mosaicBlockSize": "Mosaic Block Size",
|
||||
"blurShapeRectangle": "Rectangle",
|
||||
"blurShapeOval": "Oval",
|
||||
"blurShapeFreehand": "Freehand",
|
||||
"deleteAnnotation": "Delete Annotation",
|
||||
"shortcutsAndTips": "Shortcuts & Tips",
|
||||
"tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"addTrim": "Add Trim",
|
||||
"addSpeed": "Add Speed",
|
||||
"addAnnotation": "Add Annotation",
|
||||
"addBlur": "Add Blur",
|
||||
"addKeyframe": "Add Keyframe",
|
||||
"deleteSelected": "Delete Selected",
|
||||
"playPause": "Play / Pause"
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
"suggestZooms": "Suggest Zooms from Cursor",
|
||||
"addTrim": "Add Trim (T)",
|
||||
"addAnnotation": "Add Annotation (A)",
|
||||
"addBlur": "Add Blur (B)",
|
||||
"addSpeed": "Add Speed (S)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Press Z to add zoom",
|
||||
"pressTrim": "Press T to add trim",
|
||||
"pressAnnotation": "Press A to add annotation",
|
||||
"pressBlur": "Press B to add blur region",
|
||||
"pressSpeed": "Press S to add speed"
|
||||
},
|
||||
"labels": {
|
||||
@@ -19,6 +21,7 @@
|
||||
"trimItem": "Trim {{index}}",
|
||||
"speedItem": "Speed {{index}}",
|
||||
"annotationItem": "Annotation",
|
||||
"blurItem": "Blur {{index}}",
|
||||
"imageItem": "Image",
|
||||
"emptyText": "Empty text"
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
"systemAudioUnavailable": "Audio del sistema no disponible. Grabando sin audio del sistema.",
|
||||
"microphoneDenied": "Acceso al micrófono denegado. La grabación continuará sin audio.",
|
||||
"cameraDenied": "Acceso a la cámara denegado. La grabación continuará sin cámara web.",
|
||||
"cameraDisconnected": "Cámara web desconectada.",
|
||||
"cameraNotFound": "Cámara no encontrada.",
|
||||
"permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,11 @@
|
||||
"recording": {
|
||||
"selectSource": "Por favor selecciona una fuente para grabar"
|
||||
},
|
||||
"language": "Idioma"
|
||||
"language": "Idioma",
|
||||
"systemLanguagePrompt": {
|
||||
"title": "¿Usar el idioma del sistema?",
|
||||
"description": "Detectamos {{language}} como idioma de tu sistema. ¿Quieres cambiar OpenScreen a {{language}}?",
|
||||
"switch": "Cambiar a {{language}}",
|
||||
"keepDefault": "Mantener idioma actual"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,21 @@
|
||||
"manual": "Manual",
|
||||
"auto": "Auto",
|
||||
"autoDescription": "La cámara sigue la posición del cursor grabado"
|
||||
},
|
||||
"speed": {
|
||||
"title": "Velocidad de zoom",
|
||||
"instant": "Instantáneo",
|
||||
"fast": "Rápido",
|
||||
"smooth": "Suave",
|
||||
"lazy": "Lento"
|
||||
}
|
||||
},
|
||||
"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 +33,9 @@
|
||||
"selectPreset": "Seleccionar predefinido",
|
||||
"pictureInPicture": "Imagen en imagen",
|
||||
"verticalStack": "Apilado vertical",
|
||||
"webcamShape": "Forma de cámara"
|
||||
"dualFrame": "Marco dual",
|
||||
"webcamShape": "Forma de cámara",
|
||||
"webcamSize": "Tamaño de cámara"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Efectos de video",
|
||||
@@ -98,6 +109,7 @@
|
||||
"typeText": "Texto",
|
||||
"typeImage": "Imagen",
|
||||
"typeArrow": "Flecha",
|
||||
"typeBlur": "Desenfoque",
|
||||
"textContent": "Contenido de texto",
|
||||
"textPlaceholder": "Escribe tu texto...",
|
||||
"fontStyle": "Estilo de fuente",
|
||||
@@ -114,6 +126,18 @@
|
||||
"arrowDirection": "Dirección de la flecha",
|
||||
"strokeWidth": "Grosor del trazo: {{width}}px",
|
||||
"arrowColor": "Color de la flecha",
|
||||
"blurType": "Tipo de desenfoque",
|
||||
"blurTypeBlur": "Desenfoque",
|
||||
"blurTypeMosaic": "Desenfoque mosaico",
|
||||
"blurColor": "Color del desenfoque",
|
||||
"blurColorWhite": "Blanco",
|
||||
"blurColorBlack": "Negro",
|
||||
"blurShape": "Forma del desenfoque",
|
||||
"blurIntensity": "Intensidad del desenfoque",
|
||||
"mosaicBlockSize": "Tamano del bloque mosaico",
|
||||
"blurShapeRectangle": "Rectángulo",
|
||||
"blurShapeOval": "Óvalo",
|
||||
"blurShapeFreehand": "Mano alzada",
|
||||
"deleteAnnotation": "Eliminar anotación",
|
||||
"shortcutsAndTips": "Atajos y consejos",
|
||||
"tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"addTrim": "Agregar recorte",
|
||||
"addSpeed": "Agregar velocidad",
|
||||
"addAnnotation": "Agregar anotación",
|
||||
"addBlur": "Agregar desenfoque",
|
||||
"addKeyframe": "Agregar fotograma clave",
|
||||
"deleteSelected": "Eliminar seleccionado",
|
||||
"playPause": "Reproducir / Pausar"
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
"suggestZooms": "Sugerir zooms desde el cursor",
|
||||
"addTrim": "Agregar recorte (T)",
|
||||
"addAnnotation": "Agregar anotación (A)",
|
||||
"addSpeed": "Agregar velocidad (S)"
|
||||
"addSpeed": "Agregar velocidad (S)",
|
||||
"addBlur": "Agregar desenfoque (B)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Presiona Z para agregar zoom",
|
||||
"pressTrim": "Presiona T para agregar recorte",
|
||||
"pressAnnotation": "Presiona A para agregar anotación",
|
||||
"pressSpeed": "Presiona S para agregar velocidad"
|
||||
"pressSpeed": "Presiona S para agregar velocidad",
|
||||
"pressBlur": "Presiona B para agregar una región de desenfoque"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Desplazar",
|
||||
@@ -20,7 +22,8 @@
|
||||
"speedItem": "Velocidad {{index}}",
|
||||
"annotationItem": "Anotación",
|
||||
"imageItem": "Imagen",
|
||||
"emptyText": "Texto vacío"
|
||||
"emptyText": "Texto vacío",
|
||||
"blurItem": "Desenfoque {{index}}"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "No hay video cargado",
|
||||
|
||||
@@ -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,70 @@
|
||||
{
|
||||
"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.",
|
||||
"explanationBefore": "L'outil Coupe fonctionne en définissant les segments que vous souhaitez",
|
||||
"remove": "supprimer",
|
||||
"explanationMiddle": " — tout ce qui est",
|
||||
"covered": "couvert",
|
||||
"explanationAfter": "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",
|
||||
"step1DescriptionBefore": "Appuyez sur ",
|
||||
"step1DescriptionAfter": " 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,43 @@
|
||||
{
|
||||
"newRecording": {
|
||||
"title": "Retour à l'enregistreur",
|
||||
"description": "Votre session actuelle a été enregistrée.",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Confirmer"
|
||||
},
|
||||
"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.",
|
||||
"cameraDisconnected": "Webcam déconnectée.",
|
||||
"cameraNotFound": "Caméra introuvable.",
|
||||
"permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"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",
|
||||
"systemLanguagePrompt": {
|
||||
"title": "Utiliser la langue de votre système ?",
|
||||
"description": "Nous avons détecté {{language}} comme langue système. Voulez-vous passer OpenScreen en {{language}} ?",
|
||||
"switch": "Passer en {{language}}",
|
||||
"keepDefault": "Conserver la langue actuelle"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
{
|
||||
"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": {
|
||||
"title": "Vitesse du zoom",
|
||||
"instant": "Instantané",
|
||||
"fast": "Rapide",
|
||||
"smooth": "Fluide",
|
||||
"lazy": "Lent"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "Vitesse de lecture",
|
||||
"selectRegion": "Sélectionnez une région de vitesse à ajuster",
|
||||
"deleteRegion": "Supprimer la région de vitesse",
|
||||
"customPlaybackSpeed": "Vitesse de lecture personnalisée",
|
||||
"maxSpeedError": "La vitesse ne peut pas dépasser 16×"
|
||||
},
|
||||
"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",
|
||||
"dualFrame": "Double cadre",
|
||||
"webcamShape": "Forme de la caméra",
|
||||
"webcamSize": "Taille 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",
|
||||
"typeBlur": "Flou",
|
||||
"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",
|
||||
"blurType": "Type de flou",
|
||||
"blurTypeBlur": "Flou",
|
||||
"blurTypeMosaic": "Flou mosaique",
|
||||
"blurColor": "Couleur du flou",
|
||||
"blurColorWhite": "Blanc",
|
||||
"blurColorBlack": "Noir",
|
||||
"blurShape": "Forme du flou",
|
||||
"blurIntensity": "Intensité du flou",
|
||||
"mosaicBlockSize": "Taille des blocs de mosaique",
|
||||
"blurShapeRectangle": "Rectangle",
|
||||
"blurShapeOval": "Ovale",
|
||||
"blurShapeFreehand": "Main levée",
|
||||
"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,37 @@
|
||||
{
|
||||
"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",
|
||||
"addBlur": "Ajouter un flou",
|
||||
"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,53 @@
|
||||
{
|
||||
"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)",
|
||||
"addBlur": "Ajouter un flou (B)"
|
||||
},
|
||||
"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",
|
||||
"pressBlur": "Appuyez sur B pour ajouter une zone de flou"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "Panoramique",
|
||||
"zoom": "Zoom",
|
||||
"zoomItem": "Zoom {{index}}",
|
||||
"trimItem": "Coupe {{index}}",
|
||||
"speedItem": "Vitesse {{index}}",
|
||||
"annotationItem": "Annotation",
|
||||
"imageItem": "Image",
|
||||
"emptyText": "Texte vide",
|
||||
"blurItem": "Flou {{index}}"
|
||||
},
|
||||
"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\u0027espace disponible est insuffisant.",
|
||||
"zoomSuggestionUnavailable": "Gestionnaire de suggestions de zoom non disponible",
|
||||
"noCursorTelemetry": "Aucune télémétrie de curseur disponible",
|
||||
"noCursorTelemetryDescription": "Enregistrez d\u0027abord un screencast pour générer des suggestions basées sur le curseur.",
|
||||
"noUsableTelemetry": "Aucune télémétrie de curseur utilisable",
|
||||
"noUsableTelemetryDescription": "L\u0027enregistrement 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\u0027espace disponible est insuffisant.",
|
||||
"cannotPlaceSpeed": "Impossible de placer la vitesse ici",
|
||||
"speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l\u0027espace 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": "취소",
|
||||
"save": "저장",
|
||||
"delete": "삭제",
|
||||
"close": "닫기",
|
||||
"share": "공유",
|
||||
"done": "완료",
|
||||
"open": "열기",
|
||||
"upload": "업로드",
|
||||
"export": "내보내기",
|
||||
"file": "파일",
|
||||
"edit": "편집",
|
||||
"view": "보기",
|
||||
"window": "창",
|
||||
"quit": "종료",
|
||||
"stopRecording": "녹화 중지"
|
||||
},
|
||||
"playback": {
|
||||
"play": "재생",
|
||||
"pause": "일시정지",
|
||||
"fullscreen": "전체화면",
|
||||
"exitFullscreen": "전체화면 종료"
|
||||
},
|
||||
"locale": {
|
||||
"name": "한국어",
|
||||
"short": "KO"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"export": {
|
||||
"complete": "내보내기 완료",
|
||||
"yourFormatReady": "{{format}} 파일이 준비되었습니다",
|
||||
"showInFolder": "폴더에서 보기",
|
||||
"finalizingVideo": "비디오 내보내기 마무리 중...",
|
||||
"compilingGifProgress": "GIF 생성 중... {{progress}}%",
|
||||
"compilingGifWait": "GIF 생성 중... 잠시 시간이 걸릴 수 있습니다",
|
||||
"takeMoment": "잠시 기다려 주세요...",
|
||||
"failed": "내보내기 실패",
|
||||
"tryAgain": "다시 시도해 주세요",
|
||||
"finalizingVideoTitle": "비디오 마무리 중",
|
||||
"compilingGif": "GIF 생성 중",
|
||||
"exportingFormat": "{{format}} 내보내는 중",
|
||||
"compiling": "생성 중...",
|
||||
"renderingFrames": "프레임 렌더링 중",
|
||||
"processing": "처리 중...",
|
||||
"finalizing": "마무리 중...",
|
||||
"compilingStatus": "생성 중...",
|
||||
"status": "상태",
|
||||
"format": "형식",
|
||||
"frames": "프레임",
|
||||
"cancelExport": "내보내기 취소",
|
||||
"savedSuccessfully": "{{format}} 저장이 완료되었습니다!"
|
||||
},
|
||||
"tutorial": {
|
||||
"triggerLabel": "트리밍 사용법",
|
||||
"title": "트리밍 사용법",
|
||||
"description": "비디오에서 불필요한 부분을 잘라내는 방법을 알아보세요.",
|
||||
"explanationBefore": "트림 도구는 제거할 구간을",
|
||||
"remove": "지정",
|
||||
"explanationMiddle": "하는 방식으로 동작합니다 —",
|
||||
"covered": "빨간 트림 구간으로 덮인",
|
||||
"explanationAfter": "부분은 내보낼 때 잘려나갑니다.",
|
||||
"visualExample": "화면 예시",
|
||||
"removed": "제거됨",
|
||||
"kept": "유지됨",
|
||||
"part1": "파트 1",
|
||||
"part2": "파트 2",
|
||||
"part3": "파트 3",
|
||||
"finalVideo": "최종 비디오",
|
||||
"step1Title": "1. 트림 추가",
|
||||
"step1DescriptionBefore": "",
|
||||
"step1DescriptionAfter": "키를 누르거나 가위 아이콘을 클릭해 제거할 구간을 표시하세요.",
|
||||
"step2Title": "2. 조정",
|
||||
"step2Description": "빨간 구간의 가장자리를 드래그해 잘라낼 범위를 설정하세요."
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "저장되지 않은 변경 사항",
|
||||
"message": "저장되지 않은 변경 사항이 있습니다.",
|
||||
"detail": "닫기 전에 프로젝트를 저장하시겠습니까?",
|
||||
"saveAndClose": "저장 후 닫기",
|
||||
"discardAndClose": "저장하지 않고 닫기",
|
||||
"loadProject": "프로젝트 불러오기...",
|
||||
"saveProject": "프로젝트 저장...",
|
||||
"saveProjectAs": "다른 이름으로 프로젝트 저장..."
|
||||
},
|
||||
"fileDialogs": {
|
||||
"saveGif": "내보낸 GIF 저장",
|
||||
"saveVideo": "내보낸 비디오 저장",
|
||||
"selectVideo": "비디오 파일 선택",
|
||||
"saveProject": "OpenScreen 프로젝트 저장",
|
||||
"openProject": "OpenScreen 프로젝트 열기",
|
||||
"gifImage": "GIF 이미지",
|
||||
"mp4Video": "MP4 비디오",
|
||||
"videoFiles": "비디오 파일",
|
||||
"openscreenProject": "OpenScreen 프로젝트",
|
||||
"allFiles": "모든 파일"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"newRecording": {
|
||||
"title": "녹화로 돌아가기",
|
||||
"description": "현재 세션이 저장되었습니다.",
|
||||
"cancel": "취소",
|
||||
"confirm": "확인"
|
||||
},
|
||||
"errors": {
|
||||
"noVideoLoaded": "불러온 비디오가 없습니다",
|
||||
"videoNotReady": "비디오가 준비되지 않았습니다",
|
||||
"unableToDetermineSourcePath": "소스 비디오 경로를 확인할 수 없습니다",
|
||||
"failedToSaveGif": "GIF 저장에 실패했습니다",
|
||||
"gifExportFailed": "GIF 내보내기에 실패했습니다",
|
||||
"failedToSaveVideo": "비디오 저장에 실패했습니다",
|
||||
"exportFailed": "내보내기에 실패했습니다",
|
||||
"exportFailedWithError": "내보내기 실패: {{error}}",
|
||||
"failedToSaveExport": "내보낸 파일 저장에 실패했습니다",
|
||||
"failedToSaveExportedVideo": "내보낸 비디오 저장에 실패했습니다",
|
||||
"failedToRevealInFolder": "폴더에서 파일 표시 오류: {{error}}"
|
||||
},
|
||||
"export": {
|
||||
"canceled": "내보내기가 취소되었습니다",
|
||||
"exportedSuccessfully": "{{format}} 내보내기가 완료되었습니다"
|
||||
},
|
||||
"project": {
|
||||
"saveCanceled": "프로젝트 저장이 취소되었습니다",
|
||||
"failedToSave": "프로젝트 저장에 실패했습니다",
|
||||
"savedTo": "프로젝트가 {{path}}에 저장되었습니다",
|
||||
"failedToLoad": "프로젝트 불러오기에 실패했습니다",
|
||||
"invalidFormat": "유효하지 않은 프로젝트 파일 형식입니다",
|
||||
"loadedFrom": "{{path}}에서 프로젝트를 불러왔습니다"
|
||||
},
|
||||
"recording": {
|
||||
"failedCameraAccess": "카메라 접근 권한 요청에 실패했습니다.",
|
||||
"cameraBlocked": "카메라 접근이 차단되어 있습니다. 시스템 설정에서 권한을 허용해 주세요.",
|
||||
"systemAudioUnavailable": "시스템 오디오를 사용할 수 없습니다. 시스템 오디오 없이 녹화합니다.",
|
||||
"microphoneDenied": "마이크 접근이 거부되었습니다. 오디오 없이 녹화를 계속합니다.",
|
||||
"cameraDenied": "카메라 접근이 거부되었습니다. 웹캠 없이 녹화를 계속합니다.",
|
||||
"permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"tooltips": {
|
||||
"hideHUD": "HUD 숨기기",
|
||||
"closeApp": "앱 닫기",
|
||||
"restartRecording": "녹화 다시 시작",
|
||||
"cancelRecording": "녹화 취소",
|
||||
"pauseRecording": "녹화 일시정지",
|
||||
"resumeRecording": "녹화 재개",
|
||||
"openVideoFile": "비디오 파일 열기",
|
||||
"openProject": "프로젝트 열기"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "시스템 오디오 활성화",
|
||||
"disableSystemAudio": "시스템 오디오 비활성화",
|
||||
"enableMicrophone": "마이크 활성화",
|
||||
"disableMicrophone": "마이크 비활성화",
|
||||
"defaultMicrophone": "기본 마이크"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "웹캠 활성화",
|
||||
"disableWebcam": "웹캠 비활성화",
|
||||
"defaultCamera": "기본 카메라",
|
||||
"searching": "검색 중...",
|
||||
"noneFound": "카메라를 찾을 수 없음",
|
||||
"unavailable": "카메라를 사용할 수 없음"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "소스 불러오는 중...",
|
||||
"screens": "화면 ({{count}}개)",
|
||||
"windows": "창 ({{count}}개)",
|
||||
"defaultSourceName": "화면"
|
||||
},
|
||||
"recording": {
|
||||
"selectSource": "녹화할 소스를 선택해 주세요"
|
||||
},
|
||||
"language": "언어"
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"zoom": {
|
||||
"level": "줌 레벨",
|
||||
"selectRegion": "조정할 줌 구간을 선택하세요",
|
||||
"deleteZoom": "줌 삭제",
|
||||
"focusMode": {
|
||||
"title": "포커스 모드",
|
||||
"manual": "수동",
|
||||
"auto": "자동",
|
||||
"autoDescription": "녹화된 커서 위치를 따라 카메라가 이동합니다"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "재생 속도",
|
||||
"selectRegion": "조정할 속도 구간을 선택하세요",
|
||||
"deleteRegion": "속도 구간 삭제",
|
||||
"customPlaybackSpeed": "재생 속도 직접 입력",
|
||||
"maxSpeedError": "속도는 16×를 초과할 수 없습니다"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "트림 구간 삭제"
|
||||
},
|
||||
"layout": {
|
||||
"title": "레이아웃",
|
||||
"preset": "프리셋",
|
||||
"selectPreset": "프리셋 선택",
|
||||
"pictureInPicture": "화면 속 화면",
|
||||
"verticalStack": "세로 배치",
|
||||
"webcamShape": "카메라 모양",
|
||||
"webcamSize": "웹캠 크기"
|
||||
},
|
||||
"effects": {
|
||||
"title": "비디오 효과",
|
||||
"blurBg": "배경 흐림",
|
||||
"motionBlur": "모션 블러",
|
||||
"off": "끄기",
|
||||
"shadow": "그림자",
|
||||
"roundness": "모서리 둥글기",
|
||||
"padding": "여백"
|
||||
},
|
||||
"background": {
|
||||
"title": "배경",
|
||||
"image": "이미지",
|
||||
"color": "색상",
|
||||
"gradient": "그라디언트",
|
||||
"uploadCustom": "직접 업로드",
|
||||
"gradientLabel": "그라디언트 {{index}}"
|
||||
},
|
||||
"crop": {
|
||||
"title": "자르기",
|
||||
"cropVideo": "비디오 자르기",
|
||||
"dragInstruction": "각 면을 드래그해 자르기 영역을 조정하세요",
|
||||
"ratio": "비율",
|
||||
"free": "자유",
|
||||
"done": "완료",
|
||||
"lockAspectRatio": "화면 비율 고정",
|
||||
"unlockAspectRatio": "화면 비율 해제"
|
||||
},
|
||||
"exportFormat": {
|
||||
"mp4": "MP4",
|
||||
"gif": "GIF",
|
||||
"mp4Video": "MP4 비디오",
|
||||
"mp4Description": "고화질 비디오 파일",
|
||||
"gifAnimation": "GIF 애니메이션",
|
||||
"gifDescription": "공유용 애니메이션 이미지"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "내보내기 품질",
|
||||
"low": "낮음",
|
||||
"medium": "보통",
|
||||
"high": "높음"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "GIF 프레임 속도",
|
||||
"size": "GIF 크기",
|
||||
"loop": "GIF 반복"
|
||||
},
|
||||
"project": {
|
||||
"save": "프로젝트 저장",
|
||||
"load": "프로젝트 불러오기"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "비디오 내보내기",
|
||||
"gifButton": "GIF 내보내기",
|
||||
"chooseSaveLocation": "저장 위치 선택"
|
||||
},
|
||||
"links": {
|
||||
"reportBug": "버그 신고",
|
||||
"starOnGithub": "GitHub에 Star 남기기"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "지원하지 않는 파일 형식입니다",
|
||||
"jpgOnly": "JPG 또는 JPEG 이미지 파일을 업로드해 주세요.",
|
||||
"uploadSuccess": "커스텀 이미지가 성공적으로 업로드되었습니다!",
|
||||
"failedToUpload": "이미지 업로드에 실패했습니다",
|
||||
"errorReading": "파일을 읽는 중 오류가 발생했습니다."
|
||||
},
|
||||
"annotation": {
|
||||
"title": "주석 설정",
|
||||
"active": "활성",
|
||||
"typeText": "텍스트",
|
||||
"typeImage": "이미지",
|
||||
"typeArrow": "화살표",
|
||||
"textContent": "텍스트 내용",
|
||||
"textPlaceholder": "텍스트를 입력하세요...",
|
||||
"fontStyle": "폰트 스타일",
|
||||
"selectStyle": "스타일 선택",
|
||||
"size": "크기",
|
||||
"customFonts": "커스텀 폰트",
|
||||
"textColor": "텍스트 색상",
|
||||
"background": "배경",
|
||||
"none": "없음",
|
||||
"color": "색상",
|
||||
"clearBackground": "배경 지우기",
|
||||
"uploadImage": "이미지 업로드",
|
||||
"supportedFormats": "지원 형식: JPG, PNG, GIF, WebP",
|
||||
"arrowDirection": "화살표 방향",
|
||||
"strokeWidth": "선 두께: {{width}}px",
|
||||
"arrowColor": "화살표 색상",
|
||||
"deleteAnnotation": "주석 삭제",
|
||||
"shortcutsAndTips": "단축키 및 팁",
|
||||
"tipMovePlayhead": "재생 헤드를 주석 구간으로 옮겨 항목을 선택하세요.",
|
||||
"tipTabCycle": "Tab 키로 겹치는 항목을 순환할 수 있습니다.",
|
||||
"tipShiftTabCycle": "Shift+Tab으로 역방향 순환할 수 있습니다.",
|
||||
"invalidImageType": "지원하지 않는 파일 형식입니다",
|
||||
"imageFormatsOnly": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
|
||||
"imageUploadSuccess": "이미지가 성공적으로 업로드되었습니다!",
|
||||
"failedImageUpload": "이미지 업로드에 실패했습니다"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "클래식",
|
||||
"editor": "에디터",
|
||||
"strong": "강조",
|
||||
"typewriter": "타자기",
|
||||
"deco": "데코",
|
||||
"simple": "심플",
|
||||
"modern": "모던",
|
||||
"clean": "클린"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "Google 폰트 추가",
|
||||
"urlLabel": "Google Fonts 가져오기 URL",
|
||||
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||
"urlHelp": "Google Fonts에서 폰트 선택 → \"폰트 가져오기\" 클릭 → @import URL 복사",
|
||||
"nameLabel": "표시 이름",
|
||||
"namePlaceholder": "내 커스텀 폰트",
|
||||
"nameHelp": "폰트 선택기에서 표시될 이름입니다",
|
||||
"addButton": "폰트 추가",
|
||||
"addingButton": "추가 중...",
|
||||
"errorEmptyUrl": "Google Fonts 가져오기 URL을 입력해 주세요",
|
||||
"errorInvalidUrl": "유효한 Google Fonts URL을 입력해 주세요",
|
||||
"errorEmptyName": "폰트 이름을 입력해 주세요",
|
||||
"errorExtractFailed": "URL에서 폰트 패밀리를 추출할 수 없습니다",
|
||||
"successMessage": "\"{{fontName}}\" 폰트가 성공적으로 추가되었습니다",
|
||||
"failedToAdd": "폰트 추가에 실패했습니다",
|
||||
"errorTimeout": "폰트 로딩 시간이 초과되었습니다. URL을 확인하고 다시 시도해 주세요.",
|
||||
"errorLoadFailed": "폰트를 불러올 수 없습니다. Google Fonts URL이 올바른지 확인해 주세요."
|
||||
},
|
||||
"language": {
|
||||
"title": "언어"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"title": "키보드 단축키",
|
||||
"customize": "사용자 지정",
|
||||
"configurable": "변경 가능",
|
||||
"fixed": "고정",
|
||||
"pressKey": "키를 누르세요...",
|
||||
"clickToChange": "클릭해서 변경",
|
||||
"pressEscToCancel": "Esc를 눌러 취소",
|
||||
"helpText": "단축키를 클릭한 후 새 키 조합을 누르세요. 취소하려면 Esc를 누르세요.",
|
||||
"resetToDefaults": "기본값으로 초기화",
|
||||
"alreadyUsedBy": "이미 {{action}}에서 사용 중입니다",
|
||||
"swap": "교체",
|
||||
"reservedShortcut": "이 단축키는 \"{{label}}\"에 예약되어 있어 변경할 수 없습니다.",
|
||||
"savedToast": "키보드 단축키가 저장되었습니다",
|
||||
"resetToast": "기본 단축키로 초기화되었습니다 — 저장을 클릭해 적용하세요",
|
||||
"actions": {
|
||||
"addZoom": "줌 추가",
|
||||
"addTrim": "트림 추가",
|
||||
"addSpeed": "속도 추가",
|
||||
"addAnnotation": "주석 추가",
|
||||
"addKeyframe": "키프레임 추가",
|
||||
"deleteSelected": "선택 항목 삭제",
|
||||
"playPause": "재생 / 일시정지"
|
||||
},
|
||||
"fixedActions": {
|
||||
"undo": "실행 취소",
|
||||
"redo": "다시 실행",
|
||||
"cycleAnnotationsForward": "주석 앞으로 순환",
|
||||
"cycleAnnotationsBackward": "주석 뒤로 순환",
|
||||
"deleteSelectedAlt": "선택 항목 삭제 (대체)",
|
||||
"panTimeline": "타임라인 이동",
|
||||
"zoomTimeline": "타임라인 확대/축소",
|
||||
"frameBack": "이전 프레임",
|
||||
"frameForward": "다음 프레임"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"buttons": {
|
||||
"addZoom": "줌 추가 (Z)",
|
||||
"suggestZooms": "커서 기반 줌 제안",
|
||||
"addTrim": "트림 추가 (T)",
|
||||
"addAnnotation": "주석 추가 (A)",
|
||||
"addSpeed": "속도 추가 (S)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "Z를 눌러 줌 추가",
|
||||
"pressTrim": "T를 눌러 트림 추가",
|
||||
"pressAnnotation": "A를 눌러 주석 추가",
|
||||
"pressSpeed": "S를 눌러 속도 추가"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "이동",
|
||||
"zoom": "줌",
|
||||
"zoomItem": "줌 {{index}}",
|
||||
"trimItem": "트림 {{index}}",
|
||||
"speedItem": "속도 {{index}}",
|
||||
"annotationItem": "주석",
|
||||
"imageItem": "이미지",
|
||||
"emptyText": "빈 텍스트"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "불러온 비디오 없음",
|
||||
"dragAndDrop": "비디오를 드래그 앤 드롭해서 편집을 시작하세요"
|
||||
},
|
||||
"errors": {
|
||||
"cannotPlaceZoom": "이 위치에 줌을 추가할 수 없습니다",
|
||||
"zoomExistsAtLocation": "이 위치에 이미 줌이 있거나 공간이 부족합니다.",
|
||||
"zoomSuggestionUnavailable": "줌 제안 기능을 사용할 수 없습니다",
|
||||
"noCursorTelemetry": "커서 데이터가 없습니다",
|
||||
"noCursorTelemetryDescription": "커서 기반 제안을 생성하려면 먼저 화면을 녹화해 주세요.",
|
||||
"noUsableTelemetry": "사용 가능한 커서 데이터가 없습니다",
|
||||
"noUsableTelemetryDescription": "녹화에 충분한 커서 이동 데이터가 포함되어 있지 않습니다.",
|
||||
"noDwellMoments": "명확한 커서 정지 구간을 찾을 수 없습니다",
|
||||
"noDwellMomentsDescription": "중요한 동작에서 커서를 천천히 멈추며 녹화해 보세요.",
|
||||
"noAutoZoomSlots": "자동 줌 슬롯이 없습니다",
|
||||
"noAutoZoomSlotsDescription": "감지된 정지 지점이 기존 줌 구간과 겹칩니다.",
|
||||
"cannotPlaceTrim": "이 위치에 트림을 추가할 수 없습니다",
|
||||
"trimExistsAtLocation": "이 위치에 이미 트림이 있거나 공간이 부족합니다.",
|
||||
"cannotPlaceSpeed": "이 위치에 속도를 추가할 수 없습니다",
|
||||
"speedExistsAtLocation": "이 위치에 이미 속도 구간이 있거나 공간이 부족합니다."
|
||||
},
|
||||
"success": {
|
||||
"addedZoomSuggestions": "커서 기반 줌 제안 {{count}}개가 추가되었습니다",
|
||||
"addedZoomSuggestionsPlural": "커서 기반 줌 제안 {{count}}개가 추가되었습니다"
|
||||
}
|
||||
}
|
||||
@@ -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,70 @@
|
||||
{
|
||||
"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.",
|
||||
"explanationBefore": "Kırpma aracı, istediğiniz bölümleri",
|
||||
"remove": "kaldırmak",
|
||||
"explanationMiddle": " için kullanılır; kırmızı kırpma bölgesiyle",
|
||||
"covered": "kaplanan",
|
||||
"explanationAfter": "her şey 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",
|
||||
"step1DescriptionBefore": "Kaldırılacak bölümü işaretlemek için ",
|
||||
"step1DescriptionAfter": " 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,176 @@
|
||||
{
|
||||
"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",
|
||||
"typeBlur": "Bulanık",
|
||||
"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",
|
||||
"blurShape": "Bulanık Şekli",
|
||||
"blurIntensity": "Bulanıklık Yoğunluğu",
|
||||
"blurShapeRectangle": "Dikdörtgen",
|
||||
"blurShapeOval": "Oval",
|
||||
"blurShapeFreehand": "Serbest",
|
||||
"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,37 @@
|
||||
{
|
||||
"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",
|
||||
"addBlur": "Bulanik 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,53 @@
|
||||
{
|
||||
"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)",
|
||||
"addBlur": "Bulanık ekle (B)"
|
||||
},
|
||||
"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",
|
||||
"pressBlur": "Bulanık bölge eklemek için B 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",
|
||||
"blurItem": "Bulanık {{index}}"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
"exitFullscreen": "退出全屏"
|
||||
},
|
||||
"locale": {
|
||||
"name": "中文",
|
||||
"short": "中文"
|
||||
"name": "简体中文",
|
||||
"short": "简中"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "拖动红色区域的边缘,精确覆盖您要剪掉的部分。"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"newRecording": {
|
||||
"title": "返回录屏",
|
||||
"description": "当前会话已保存。",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认"
|
||||
},
|
||||
"errors": {
|
||||
"noVideoLoaded": "未加载视频",
|
||||
"videoNotReady": "视频未就绪",
|
||||
@@ -30,6 +36,8 @@
|
||||
"systemAudioUnavailable": "系统音频不可用。将在无系统音频的情况下录制。",
|
||||
"microphoneDenied": "麦克风权限被拒绝。录制将继续,但不包含音频。",
|
||||
"cameraDenied": "摄像头权限被拒绝。录制将继续,但不包含摄像头画面。",
|
||||
"cameraDisconnected": "摄像头已断开连接。",
|
||||
"cameraNotFound": "未找到摄像头。",
|
||||
"permissionDenied": "录屏权限被拒绝。请允许屏幕录制。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,11 @@
|
||||
"recording": {
|
||||
"selectSource": "请选择要录制的源"
|
||||
},
|
||||
"language": "语言"
|
||||
"language": "语言",
|
||||
"systemLanguagePrompt": {
|
||||
"title": "使用系统语言吗?",
|
||||
"description": "我们检测到你的系统语言是{{language}}。是否将 OpenScreen 切换为{{language}}?",
|
||||
"switch": "切换到{{language}}",
|
||||
"keepDefault": "保持当前语言"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,21 @@
|
||||
"manual": "手动",
|
||||
"auto": "自动",
|
||||
"autoDescription": "摄像头跟随录制时的光标位置"
|
||||
},
|
||||
"speed": {
|
||||
"title": "缩放速度",
|
||||
"instant": "即时",
|
||||
"fast": "快速",
|
||||
"smooth": "平滑",
|
||||
"lazy": "缓慢"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "播放速度",
|
||||
"selectRegion": "选择要调整的速度区域",
|
||||
"deleteRegion": "删除速度区域"
|
||||
"deleteRegion": "删除速度区域",
|
||||
"customPlaybackSpeed": "自定义播放速度",
|
||||
"maxSpeedError": "速度不能超过 16×"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "删除剪辑区域"
|
||||
@@ -24,7 +33,9 @@
|
||||
"selectPreset": "选择预设",
|
||||
"pictureInPicture": "画中画",
|
||||
"verticalStack": "垂直堆叠",
|
||||
"webcamShape": "摄像头形状"
|
||||
"dualFrame": "双画框",
|
||||
"webcamShape": "摄像头形状",
|
||||
"webcamSize": "摄像头大小"
|
||||
},
|
||||
"effects": {
|
||||
"title": "视频效果",
|
||||
@@ -98,6 +109,7 @@
|
||||
"typeText": "文本",
|
||||
"typeImage": "图片",
|
||||
"typeArrow": "箭头",
|
||||
"typeBlur": "模糊",
|
||||
"textContent": "文本内容",
|
||||
"textPlaceholder": "输入您的文本...",
|
||||
"fontStyle": "字体样式",
|
||||
@@ -114,6 +126,11 @@
|
||||
"arrowDirection": "箭头方向",
|
||||
"strokeWidth": "描边宽度:{{width}}px",
|
||||
"arrowColor": "箭头颜色",
|
||||
"blurShape": "模糊形状",
|
||||
"blurIntensity": "模糊强度",
|
||||
"blurShapeRectangle": "矩形",
|
||||
"blurShapeOval": "椭圆",
|
||||
"blurShapeFreehand": "自由手绘",
|
||||
"deleteAnnotation": "删除标注",
|
||||
"shortcutsAndTips": "快捷键与提示",
|
||||
"tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"addTrim": "添加剪辑",
|
||||
"addSpeed": "添加速度",
|
||||
"addAnnotation": "添加标注",
|
||||
"addBlur": "添加模糊",
|
||||
"addKeyframe": "添加关键帧",
|
||||
"deleteSelected": "删除所选",
|
||||
"playPause": "播放 / 暂停"
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
"suggestZooms": "根据光标建议缩放",
|
||||
"addTrim": "添加剪辑 (T)",
|
||||
"addAnnotation": "添加标注 (A)",
|
||||
"addSpeed": "添加速度 (S)"
|
||||
"addSpeed": "添加速度 (S)",
|
||||
"addBlur": "添加模糊 (B)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "按 Z 添加缩放",
|
||||
"pressTrim": "按 T 添加剪辑",
|
||||
"pressAnnotation": "按 A 添加标注",
|
||||
"pressSpeed": "按 S 添加速度"
|
||||
"pressSpeed": "按 S 添加速度",
|
||||
"pressBlur": "按 B 添加模糊区域"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "平移",
|
||||
@@ -20,7 +22,8 @@
|
||||
"speedItem": "速度 {{index}}",
|
||||
"annotationItem": "标注",
|
||||
"imageItem": "图片",
|
||||
"emptyText": "空文本"
|
||||
"emptyText": "空文本",
|
||||
"blurItem": "模糊 {{index}}"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "未加载视频",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"actions": {
|
||||
"cancel": "取消",
|
||||
"save": "儲存",
|
||||
"delete": "刪除",
|
||||
"close": "關閉",
|
||||
"share": "分享",
|
||||
"done": "完成",
|
||||
"open": "開啟",
|
||||
"upload": "上傳",
|
||||
"export": "匯出",
|
||||
"file": "檔案",
|
||||
"edit": "編輯",
|
||||
"view": "檢視",
|
||||
"window": "視窗",
|
||||
"quit": "退出",
|
||||
"stopRecording": "停止錄製"
|
||||
},
|
||||
"playback": {
|
||||
"play": "播放",
|
||||
"pause": "暫停",
|
||||
"fullscreen": "全螢幕",
|
||||
"exitFullscreen": "退出全螢幕"
|
||||
},
|
||||
"locale": {
|
||||
"name": "繁體中文",
|
||||
"short": "繁中"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"export": {
|
||||
"complete": "匯出完成",
|
||||
"yourFormatReady": "您的 {{format}} 已準備就緒",
|
||||
"showInFolder": "在資料夾中顯示",
|
||||
"finalizingVideo": "正在完成影片匯出...",
|
||||
"compilingGifProgress": "正在編譯 GIF... {{progress}}%",
|
||||
"compilingGifWait": "正在編譯 GIF... 這可能需要一些時間",
|
||||
"takeMoment": "這可能需要一點時間...",
|
||||
"failed": "匯出失敗",
|
||||
"tryAgain": "請重試",
|
||||
"finalizingVideoTitle": "正在完成影片",
|
||||
"compilingGif": "正在編譯 GIF",
|
||||
"exportingFormat": "正在匯出 {{format}}",
|
||||
"compiling": "編譯中",
|
||||
"renderingFrames": "渲染影格",
|
||||
"processing": "處理中...",
|
||||
"finalizing": "正在完成...",
|
||||
"compilingStatus": "編譯中...",
|
||||
"status": "狀態",
|
||||
"format": "格式",
|
||||
"frames": "影格",
|
||||
"cancelExport": "取消匯出",
|
||||
"savedSuccessfully": "{{format}} 儲存成功!"
|
||||
},
|
||||
"tutorial": {
|
||||
"triggerLabel": "剪輯功能說明",
|
||||
"title": "剪輯功能說明",
|
||||
"description": "了解如何剪掉影片中不需要的部分。",
|
||||
"explanationBefore": "剪輯工具透過定義您要",
|
||||
"remove": "移除",
|
||||
"explanationMiddle": "——任何被",
|
||||
"covered": "覆蓋",
|
||||
"explanationAfter": "的紅色剪輯區域部分將在匯出時被剪掉。",
|
||||
"visualExample": "示例演示",
|
||||
"removed": "已移除",
|
||||
"kept": "保留",
|
||||
"part1": "第 1 部分",
|
||||
"part2": "第 2 部分",
|
||||
"part3": "第 3 部分",
|
||||
"finalVideo": "最終影片",
|
||||
"step1Title": "1. 添加剪輯",
|
||||
"step1DescriptionBefore": "按",
|
||||
"step1DescriptionAfter": "鍵或點擊剪刀圖示來標記要移除的片段。",
|
||||
"step2Title": "2. 調整",
|
||||
"step2Description": "拖動紅色區域的邊緣,精確覆蓋您要剪掉的部分。"
|
||||
},
|
||||
"unsavedChanges": {
|
||||
"title": "未儲存的變更",
|
||||
"message": "您有未儲存的變更。",
|
||||
"detail": "是否在關閉前儲存專案?",
|
||||
"saveAndClose": "儲存並關閉",
|
||||
"discardAndClose": "捨棄並關閉",
|
||||
"loadProject": "載入專案…",
|
||||
"saveProject": "儲存專案…",
|
||||
"saveProjectAs": "專案另存新檔…"
|
||||
},
|
||||
"fileDialogs": {
|
||||
"saveGif": "儲存匯出的 GIF",
|
||||
"saveVideo": "儲存匯出的影片",
|
||||
"selectVideo": "選擇影片檔案",
|
||||
"saveProject": "儲存 OpenScreen 專案",
|
||||
"openProject": "開啟 OpenScreen 專案",
|
||||
"gifImage": "GIF 圖片",
|
||||
"mp4Video": "MP4 影片",
|
||||
"videoFiles": "影片檔案",
|
||||
"openscreenProject": "OpenScreen 專案",
|
||||
"allFiles": "所有檔案"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"newRecording": {
|
||||
"title": "返回錄影",
|
||||
"description": "目前工作階段已儲存。",
|
||||
"cancel": "取消",
|
||||
"confirm": "確認"
|
||||
},
|
||||
"errors": {
|
||||
"noVideoLoaded": "未載入影片",
|
||||
"videoNotReady": "影片未就緒",
|
||||
"unableToDetermineSourcePath": "無法確定來源影片路徑",
|
||||
"failedToSaveGif": "儲存 GIF 失敗",
|
||||
"gifExportFailed": "GIF 匯出失敗",
|
||||
"failedToSaveVideo": "儲存影片失敗",
|
||||
"exportFailed": "匯出失敗",
|
||||
"exportFailedWithError": "匯出失敗:{{error}}",
|
||||
"failedToSaveExport": "儲存匯出檔案失敗",
|
||||
"failedToSaveExportedVideo": "儲存匯出的影片失敗",
|
||||
"failedToRevealInFolder": "在資料夾中顯示時出錯:{{error}}"
|
||||
},
|
||||
"export": {
|
||||
"canceled": "匯出已取消",
|
||||
"exportedSuccessfully": "{{format}} 匯出成功"
|
||||
},
|
||||
"project": {
|
||||
"saveCanceled": "專案儲存已取消",
|
||||
"failedToSave": "儲存專案失敗",
|
||||
"savedTo": "專案已儲存至 {{path}}",
|
||||
"failedToLoad": "載入專案失敗",
|
||||
"invalidFormat": "無效的專案檔案格式",
|
||||
"loadedFrom": "專案已從 {{path}} 載入"
|
||||
},
|
||||
"recording": {
|
||||
"failedCameraAccess": "請求攝影機權限失敗。",
|
||||
"cameraBlocked": "攝影機權限已被封鎖。請在系統設定中啟用以使用攝影機。",
|
||||
"systemAudioUnavailable": "系統音訊不可用。將在無系統音訊的情況下錄製。",
|
||||
"microphoneDenied": "麥克風權限被拒絕。錄製將繼續,但不包含音訊。",
|
||||
"cameraDenied": "攝影機權限被拒絕。錄製將繼續,但不包含攝影機畫面。",
|
||||
"permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"tooltips": {
|
||||
"hideHUD": "隱藏控制面板",
|
||||
"closeApp": "關閉應用程式",
|
||||
"restartRecording": "重新開始錄製",
|
||||
"cancelRecording": "取消錄製",
|
||||
"pauseRecording": "暫停錄製",
|
||||
"resumeRecording": "繼續錄製",
|
||||
"openVideoFile": "開啟影片檔案",
|
||||
"openProject": "開啟專案"
|
||||
},
|
||||
"audio": {
|
||||
"enableSystemAudio": "啟用系統音訊",
|
||||
"disableSystemAudio": "停用系統音訊",
|
||||
"enableMicrophone": "啟用麥克風",
|
||||
"disableMicrophone": "停用麥克風",
|
||||
"defaultMicrophone": "預設麥克風"
|
||||
},
|
||||
"webcam": {
|
||||
"enableWebcam": "啟用攝影機",
|
||||
"disableWebcam": "停用攝影機",
|
||||
"defaultCamera": "預設攝影機",
|
||||
"searching": "正在搜尋...",
|
||||
"noneFound": "未找到攝影機",
|
||||
"unavailable": "攝影機不可用"
|
||||
},
|
||||
"sourceSelector": {
|
||||
"loading": "正在載入來源...",
|
||||
"screens": "螢幕 ({{count}})",
|
||||
"windows": "視窗 ({{count}})",
|
||||
"defaultSourceName": "螢幕"
|
||||
},
|
||||
"recording": {
|
||||
"selectSource": "請選擇要錄製的來源"
|
||||
},
|
||||
"language": "語言"
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"zoom": {
|
||||
"level": "縮放級別",
|
||||
"selectRegion": "選擇要調整的縮放區域",
|
||||
"deleteZoom": "刪除縮放",
|
||||
"focusMode": {
|
||||
"title": "對焦模式",
|
||||
"manual": "手動",
|
||||
"auto": "自動",
|
||||
"autoDescription": "攝影機跟隨錄製時的游標位置"
|
||||
},
|
||||
"speed": {
|
||||
"title": "縮放速度",
|
||||
"instant": "即時",
|
||||
"fast": "快速",
|
||||
"smooth": "平滑",
|
||||
"lazy": "緩慢"
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"playbackSpeed": "播放速度",
|
||||
"selectRegion": "選擇要調整的速度區域",
|
||||
"deleteRegion": "刪除速度區域",
|
||||
"customPlaybackSpeed": "自訂播放速度",
|
||||
"maxSpeedError": "速度不能超過 16×"
|
||||
},
|
||||
"trim": {
|
||||
"deleteRegion": "刪除剪輯區域"
|
||||
},
|
||||
"layout": {
|
||||
"title": "版面配置",
|
||||
"preset": "預設",
|
||||
"selectPreset": "選擇預設",
|
||||
"pictureInPicture": "子母畫面",
|
||||
"verticalStack": "垂直堆疊",
|
||||
"dualFrame": "雙畫框",
|
||||
"webcamShape": "攝影機形狀",
|
||||
"webcamSize": "攝影機大小"
|
||||
},
|
||||
"effects": {
|
||||
"title": "影片效果",
|
||||
"blurBg": "模糊背景",
|
||||
"motionBlur": "動態模糊",
|
||||
"off": "關",
|
||||
"shadow": "陰影",
|
||||
"roundness": "圓角",
|
||||
"padding": "內邊距"
|
||||
},
|
||||
"background": {
|
||||
"title": "背景",
|
||||
"image": "圖片",
|
||||
"color": "顏色",
|
||||
"gradient": "漸層",
|
||||
"uploadCustom": "上傳自訂",
|
||||
"gradientLabel": "漸層 {{index}}"
|
||||
},
|
||||
"crop": {
|
||||
"title": "裁剪",
|
||||
"cropVideo": "裁剪影片",
|
||||
"dragInstruction": "拖動每一側來調整裁剪區域",
|
||||
"ratio": "比例",
|
||||
"free": "自由",
|
||||
"done": "完成",
|
||||
"lockAspectRatio": "鎖定長寬比",
|
||||
"unlockAspectRatio": "解鎖長寬比"
|
||||
},
|
||||
"exportFormat": {
|
||||
"mp4": "MP4",
|
||||
"gif": "GIF",
|
||||
"mp4Video": "MP4 影片",
|
||||
"mp4Description": "高品質影片檔案",
|
||||
"gifAnimation": "GIF 動畫",
|
||||
"gifDescription": "可分享的動態圖片"
|
||||
},
|
||||
"exportQuality": {
|
||||
"title": "匯出品質",
|
||||
"low": "低",
|
||||
"medium": "中",
|
||||
"high": "高"
|
||||
},
|
||||
"gifSettings": {
|
||||
"frameRate": "GIF 影格率",
|
||||
"size": "GIF 尺寸",
|
||||
"loop": "循環 GIF"
|
||||
},
|
||||
"project": {
|
||||
"save": "儲存專案",
|
||||
"load": "載入專案"
|
||||
},
|
||||
"export": {
|
||||
"videoButton": "匯出影片",
|
||||
"gifButton": "匯出 GIF",
|
||||
"chooseSaveLocation": "選擇儲存位置"
|
||||
},
|
||||
"links": {
|
||||
"reportBug": "回報錯誤",
|
||||
"starOnGithub": "在 GitHub 上加星"
|
||||
},
|
||||
"imageUpload": {
|
||||
"invalidFileType": "無效的檔案類型",
|
||||
"jpgOnly": "請上傳 JPG 或 JPEG 格式的圖片檔案。",
|
||||
"uploadSuccess": "自訂圖片上傳成功!",
|
||||
"failedToUpload": "上傳圖片失敗",
|
||||
"errorReading": "讀取檔案時出錯。"
|
||||
},
|
||||
"annotation": {
|
||||
"title": "標註設定",
|
||||
"active": "啟用",
|
||||
"typeText": "文字",
|
||||
"typeImage": "圖片",
|
||||
"typeArrow": "箭頭",
|
||||
"typeBlur": "模糊",
|
||||
"textContent": "文字內容",
|
||||
"textPlaceholder": "輸入您的文字...",
|
||||
"fontStyle": "字體樣式",
|
||||
"selectStyle": "選擇樣式",
|
||||
"size": "大小",
|
||||
"customFonts": "自訂字體",
|
||||
"textColor": "文字顏色",
|
||||
"background": "背景",
|
||||
"none": "無",
|
||||
"color": "顏色",
|
||||
"clearBackground": "清除背景",
|
||||
"uploadImage": "上傳圖片",
|
||||
"supportedFormats": "支援的格式:JPG、PNG、GIF、WebP",
|
||||
"arrowDirection": "箭頭方向",
|
||||
"strokeWidth": "描邊寬度:{{width}}px",
|
||||
"arrowColor": "箭頭顏色",
|
||||
"blurShape": "模糊形狀",
|
||||
"blurIntensity": "模糊強度",
|
||||
"blurShapeRectangle": "矩形",
|
||||
"blurShapeOval": "橢圓",
|
||||
"blurShapeFreehand": "自由手繪",
|
||||
"deleteAnnotation": "刪除標註",
|
||||
"shortcutsAndTips": "快捷鍵與提示",
|
||||
"tipMovePlayhead": "將播放頭移動到重疊的標註區域並選擇一個項目。",
|
||||
"tipTabCycle": "使用 Tab 鍵在重疊項目之間循環切換。",
|
||||
"tipShiftTabCycle": "使用 Shift+Tab 反向循環切換。",
|
||||
"invalidImageType": "無效的檔案類型",
|
||||
"imageFormatsOnly": "請上傳 JPG、PNG、GIF 或 WebP 格式的圖片檔案。",
|
||||
"imageUploadSuccess": "圖片上傳成功!",
|
||||
"failedImageUpload": "上傳圖片失敗"
|
||||
},
|
||||
"fontStyles": {
|
||||
"classic": "經典",
|
||||
"editor": "編輯器",
|
||||
"strong": "粗體",
|
||||
"typewriter": "打字機",
|
||||
"deco": "裝飾",
|
||||
"simple": "簡約",
|
||||
"modern": "現代",
|
||||
"clean": "簡潔"
|
||||
},
|
||||
"customFont": {
|
||||
"dialogTitle": "新增 Google 字體",
|
||||
"urlLabel": "Google Fonts 匯入 URL",
|
||||
"urlPlaceholder": "https://fonts.googleapis.com/css2?family=Roboto&display=swap",
|
||||
"urlHelp": "從 Google Fonts 取得:選擇字體 → 點擊 \"Get font\" → 複製 @import URL",
|
||||
"nameLabel": "顯示名稱",
|
||||
"namePlaceholder": "我的自訂字體",
|
||||
"nameHelp": "這是字體在字體選擇器中顯示的名稱",
|
||||
"addButton": "新增字體",
|
||||
"addingButton": "新增中...",
|
||||
"errorEmptyUrl": "請輸入 Google Fonts 匯入 URL",
|
||||
"errorInvalidUrl": "請輸入有效的 Google Fonts URL",
|
||||
"errorEmptyName": "請輸入字體名稱",
|
||||
"errorExtractFailed": "無法從 URL 中提取字體系列",
|
||||
"successMessage": "字體 \"{{fontName}}\" 新增成功",
|
||||
"failedToAdd": "新增字體失敗",
|
||||
"errorTimeout": "字體載入時間過長。請檢查 URL 並重試。",
|
||||
"errorLoadFailed": "無法載入該字體。請確認 Google Fonts URL 是否正確。"
|
||||
},
|
||||
"language": {
|
||||
"title": "語言"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "鍵盤快捷鍵",
|
||||
"customize": "自訂",
|
||||
"configurable": "可設定",
|
||||
"fixed": "固定",
|
||||
"pressKey": "請按下按鍵…",
|
||||
"clickToChange": "點擊以變更",
|
||||
"pressEscToCancel": "按 Esc 取消",
|
||||
"helpText": "點擊一個快捷鍵,然後按下新的組合鍵。按 Esc 取消。",
|
||||
"resetToDefaults": "還原預設設定",
|
||||
"alreadyUsedBy": "已被 \"{{action}}\" 使用",
|
||||
"swap": "交換",
|
||||
"reservedShortcut": "此快捷鍵已保留給 \"{{label}}\",無法重新指定。",
|
||||
"savedToast": "鍵盤快捷鍵已儲存",
|
||||
"resetToast": "已還原預設快捷鍵 — 點擊儲存以套用",
|
||||
"actions": {
|
||||
"addZoom": "新增縮放",
|
||||
"addTrim": "新增剪輯",
|
||||
"addSpeed": "新增速度",
|
||||
"addAnnotation": "新增標註",
|
||||
"addBlur": "新增模糊",
|
||||
"addKeyframe": "新增關鍵影格",
|
||||
"deleteSelected": "刪除所選",
|
||||
"playPause": "播放 / 暫停"
|
||||
},
|
||||
"fixedActions": {
|
||||
"undo": "復原",
|
||||
"redo": "重做",
|
||||
"cycleAnnotationsForward": "向前切換標註",
|
||||
"cycleAnnotationsBackward": "向後切換標註",
|
||||
"deleteSelectedAlt": "刪除所選(替代)",
|
||||
"panTimeline": "平移時間軸",
|
||||
"zoomTimeline": "縮放時間軸",
|
||||
"frameBack": "上一影格",
|
||||
"frameForward": "下一影格"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"buttons": {
|
||||
"addZoom": "新增縮放 (Z)",
|
||||
"suggestZooms": "根據游標建議縮放",
|
||||
"addTrim": "新增剪輯 (T)",
|
||||
"addAnnotation": "新增標註 (A)",
|
||||
"addSpeed": "新增速度 (S)",
|
||||
"addBlur": "新增模糊 (B)"
|
||||
},
|
||||
"hints": {
|
||||
"pressZoom": "按 Z 新增縮放",
|
||||
"pressTrim": "按 T 新增剪輯",
|
||||
"pressAnnotation": "按 A 新增標註",
|
||||
"pressSpeed": "按 S 新增速度",
|
||||
"pressBlur": "按 B 新增模糊區域"
|
||||
},
|
||||
"labels": {
|
||||
"pan": "平移",
|
||||
"zoom": "縮放",
|
||||
"zoomItem": "縮放 {{index}}",
|
||||
"trimItem": "剪輯 {{index}}",
|
||||
"speedItem": "速度 {{index}}",
|
||||
"annotationItem": "標註",
|
||||
"imageItem": "圖片",
|
||||
"emptyText": "空文字",
|
||||
"blurItem": "模糊 {{index}}"
|
||||
},
|
||||
"emptyState": {
|
||||
"noVideo": "未載入影片",
|
||||
"dragAndDrop": "拖放影片以開始編輯"
|
||||
},
|
||||
"errors": {
|
||||
"cannotPlaceZoom": "無法在此處放置縮放",
|
||||
"zoomExistsAtLocation": "此位置已存在縮放或沒有足夠的空間。",
|
||||
"zoomSuggestionUnavailable": "縮放建議處理器不可用",
|
||||
"noCursorTelemetry": "無可用的游標遙測資料",
|
||||
"noCursorTelemetryDescription": "請先錄製一段螢幕錄影以產生基於游標的建議。",
|
||||
"noUsableTelemetry": "無可用的游標遙測資料",
|
||||
"noUsableTelemetryDescription": "錄製內容沒有包含足夠的游標移動資料。",
|
||||
"noDwellMoments": "未找到明確的游標停留時刻",
|
||||
"noDwellMomentsDescription": "請嘗試在重要操作上進行較慢游標停留的錄製。",
|
||||
"noAutoZoomSlots": "無可用的自動縮放位置",
|
||||
"noAutoZoomSlotsDescription": "偵測到的停留點與現有縮放區域重疊。",
|
||||
"cannotPlaceTrim": "無法在此處放置剪輯",
|
||||
"trimExistsAtLocation": "此位置已存在剪輯或沒有足夠的空間。",
|
||||
"cannotPlaceSpeed": "無法在此處放置速度",
|
||||
"speedExistsAtLocation": "此位置已存在速度區域或沒有足夠的空間。"
|
||||
},
|
||||
"success": {
|
||||
"addedZoomSuggestions": "已新增 {{count}} 個基於游標的縮放建議",
|
||||
"addedZoomSuggestionsPlural": "已新增 {{count}} 個基於游標的縮放建議"
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,10 @@
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
.squircle {
|
||||
corner-shape: squircle;
|
||||
}
|
||||
|
||||
/* Smooth playback scrubber */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyMosaicToImageData, getBlurOverlayColor, normalizeBlurColor } from "./blurEffects";
|
||||
|
||||
function createTestImageData(width: number, height: number) {
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const offset = (y * width + x) * 4;
|
||||
data[offset] = x * 20 + y;
|
||||
data[offset + 1] = y * 20 + x;
|
||||
data[offset + 2] = (x + y) * 10;
|
||||
data[offset + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
width,
|
||||
height,
|
||||
} as ImageData;
|
||||
}
|
||||
|
||||
describe("applyMosaicToImageData", () => {
|
||||
it("collapses each block to a single representative color", () => {
|
||||
const imageData = createTestImageData(4, 4);
|
||||
const original = new Uint8ClampedArray(imageData.data);
|
||||
|
||||
applyMosaicToImageData(imageData, 2);
|
||||
|
||||
const topLeft = Array.from(imageData.data.slice(0, 4));
|
||||
const topRightOffset = (1 * 4 + 1) * 4;
|
||||
const topRight = Array.from(imageData.data.slice(topRightOffset, topRightOffset + 4));
|
||||
expect(topLeft).toEqual(topRight);
|
||||
|
||||
expect(Array.from(original.slice(0, 4))).not.toEqual(topLeft);
|
||||
});
|
||||
|
||||
it("reduces unique pixel colors, making the transform information-lossy", () => {
|
||||
const imageData = createTestImageData(8, 8);
|
||||
const before = new Set<string>();
|
||||
const after = new Set<string>();
|
||||
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
before.add(
|
||||
`${imageData.data[i]}-${imageData.data[i + 1]}-${imageData.data[i + 2]}-${imageData.data[i + 3]}`,
|
||||
);
|
||||
}
|
||||
|
||||
applyMosaicToImageData(imageData, 4);
|
||||
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
after.add(
|
||||
`${imageData.data[i]}-${imageData.data[i + 1]}-${imageData.data[i + 2]}-${imageData.data[i + 3]}`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(after.size).toBeLessThan(before.size);
|
||||
expect(after.size).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("blur color helpers", () => {
|
||||
it("normalizes invalid blur colors to white", () => {
|
||||
expect(normalizeBlurColor("black")).toBe("black");
|
||||
expect(normalizeBlurColor("invalid")).toBe("white");
|
||||
});
|
||||
|
||||
it("returns a dark overlay when black blur color is selected", () => {
|
||||
expect(
|
||||
getBlurOverlayColor({
|
||||
type: "blur",
|
||||
shape: "rectangle",
|
||||
color: "black",
|
||||
intensity: 12,
|
||||
blockSize: 12,
|
||||
}),
|
||||
).toBe("rgba(0, 0, 0, 0.18)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
type BlurColor,
|
||||
type BlurData,
|
||||
type BlurType,
|
||||
DEFAULT_BLUR_BLOCK_SIZE,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
MAX_BLUR_BLOCK_SIZE,
|
||||
MAX_BLUR_INTENSITY,
|
||||
MIN_BLUR_BLOCK_SIZE,
|
||||
MIN_BLUR_INTENSITY,
|
||||
} from "@/components/video-editor/types";
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function normalizeBlurType(value: unknown): BlurType {
|
||||
return value === "mosaic" ? "mosaic" : "blur";
|
||||
}
|
||||
|
||||
export function normalizeBlurColor(value: unknown): BlurColor {
|
||||
return value === "black" ? "black" : "white";
|
||||
}
|
||||
|
||||
export function getNormalizedBlurIntensity(blurData?: BlurData | null): number {
|
||||
return clamp(
|
||||
blurData?.intensity ?? DEFAULT_BLUR_INTENSITY,
|
||||
MIN_BLUR_INTENSITY,
|
||||
MAX_BLUR_INTENSITY,
|
||||
);
|
||||
}
|
||||
|
||||
export function getNormalizedMosaicBlockSize(blurData?: BlurData | null, scaleFactor = 1): number {
|
||||
const rawBlockSize = clamp(
|
||||
blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE,
|
||||
MIN_BLUR_BLOCK_SIZE,
|
||||
MAX_BLUR_BLOCK_SIZE,
|
||||
);
|
||||
return Math.max(1, Math.round(rawBlockSize * Math.max(scaleFactor, 0.01)));
|
||||
}
|
||||
|
||||
export function getBlurOverlayColor(blurData?: BlurData | null): string {
|
||||
const blurColor = normalizeBlurColor(blurData?.color);
|
||||
const blurType = normalizeBlurType(blurData?.type);
|
||||
|
||||
if (blurColor === "black") {
|
||||
return blurType === "mosaic" ? "rgba(0, 0, 0, 0.72)" : "rgba(0, 0, 0, 0.56)";
|
||||
}
|
||||
|
||||
return blurType === "mosaic" ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.02)";
|
||||
}
|
||||
|
||||
export function getMosaicGridOverlayColor(blurData?: BlurData | null): string {
|
||||
return normalizeBlurColor(blurData?.color) === "black"
|
||||
? "rgba(255,255,255,0.05)"
|
||||
: "rgba(255,255,255,0.04)";
|
||||
}
|
||||
|
||||
export function applyMosaicToImageData(imageData: ImageData, blockSize: number): ImageData {
|
||||
const width = imageData.width;
|
||||
const height = imageData.height;
|
||||
const data = imageData.data;
|
||||
const normalizedBlockSize = Math.max(1, Math.floor(blockSize));
|
||||
|
||||
if (width <= 0 || height <= 0 || normalizedBlockSize <= 1) {
|
||||
return imageData;
|
||||
}
|
||||
|
||||
for (let blockY = 0; blockY < height; blockY += normalizedBlockSize) {
|
||||
for (let blockX = 0; blockX < width; blockX += normalizedBlockSize) {
|
||||
const blockWidth = Math.min(normalizedBlockSize, width - blockX);
|
||||
const blockHeight = Math.min(normalizedBlockSize, height - blockY);
|
||||
const pixelCount = blockWidth * blockHeight;
|
||||
|
||||
if (pixelCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let redTotal = 0;
|
||||
let greenTotal = 0;
|
||||
let blueTotal = 0;
|
||||
let alphaTotal = 0;
|
||||
|
||||
for (let y = blockY; y < blockY + blockHeight; y++) {
|
||||
for (let x = blockX; x < blockX + blockWidth; x++) {
|
||||
const offset = (y * width + x) * 4;
|
||||
redTotal += data[offset];
|
||||
greenTotal += data[offset + 1];
|
||||
blueTotal += data[offset + 2];
|
||||
alphaTotal += data[offset + 3];
|
||||
}
|
||||
}
|
||||
|
||||
const averageRed = Math.round(redTotal / pixelCount);
|
||||
const averageGreen = Math.round(greenTotal / pixelCount);
|
||||
const averageBlue = Math.round(blueTotal / pixelCount);
|
||||
const averageAlpha = Math.round(alphaTotal / pixelCount);
|
||||
|
||||
for (let y = blockY; y < blockY + blockHeight; y++) {
|
||||
for (let x = blockX; x < blockX + blockWidth; x++) {
|
||||
const offset = (y * width + x) * 4;
|
||||
data[offset] = averageRed;
|
||||
data[offset + 1] = averageGreen;
|
||||
data[offset + 2] = averageBlue;
|
||||
data[offset + 3] = averageAlpha;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return imageData;
|
||||
}
|
||||
+132
-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 },
|
||||
@@ -78,6 +169,29 @@ describe("computeCompositeLayout", () => {
|
||||
expect(layout?.screenCover).toBe(true);
|
||||
});
|
||||
|
||||
it("uses a 2:1 split layout in dual frame mode", () => {
|
||||
const layout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
maxContentSize: { width: 1536, height: 864 },
|
||||
screenSize: { width: 1920, height: 1080 },
|
||||
webcamSize: { width: 1280, height: 720 },
|
||||
layoutPreset: "dual-frame",
|
||||
});
|
||||
|
||||
expect(layout).not.toBeNull();
|
||||
expect(layout?.webcamRect).not.toBeNull();
|
||||
expect(layout?.screenRect.y).toBe(108);
|
||||
expect(layout?.screenRect.height).toBe(864);
|
||||
expect(layout?.screenBorderRadius).toBe(layout?.webcamRect?.borderRadius);
|
||||
expect(layout?.webcamRect?.y).toBe(108);
|
||||
expect(layout?.webcamRect?.height).toBe(864);
|
||||
expect(layout?.webcamRect?.x).toBeGreaterThan(layout?.screenRect.x ?? 0);
|
||||
expect(
|
||||
Math.abs((layout?.screenRect.width ?? 0) - 2 * (layout?.webcamRect?.width ?? 0)),
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(layout?.screenCover).toBe(true);
|
||||
});
|
||||
|
||||
it("forces circular and square masks to use square dimensions", () => {
|
||||
const circularLayout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
|
||||
+120
-10
@@ -15,7 +15,9 @@ export interface Size {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack";
|
||||
export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack" | "dual-frame";
|
||||
/** 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;
|
||||
@@ -43,9 +44,17 @@ interface StackTransform {
|
||||
gap: number;
|
||||
}
|
||||
|
||||
interface SplitTransform {
|
||||
type: "split";
|
||||
gapFraction: number;
|
||||
minGap: number;
|
||||
screenUnits: number;
|
||||
webcamUnits: number;
|
||||
}
|
||||
|
||||
export interface WebcamLayoutPresetDefinition {
|
||||
label: string;
|
||||
transform: OverlayTransform | StackTransform;
|
||||
transform: OverlayTransform | StackTransform | SplitTransform;
|
||||
borderRadius: BorderRadiusRule;
|
||||
shadow: WebcamLayoutShadow | null;
|
||||
}
|
||||
@@ -53,11 +62,18 @@ export interface WebcamLayoutPresetDefinition {
|
||||
export interface WebcamCompositeLayout {
|
||||
screenRect: RenderRect;
|
||||
webcamRect: StyledRenderRect | null;
|
||||
screenBorderRadius?: number;
|
||||
/** When true, the video should be scaled to cover screenRect (cropping overflow). */
|
||||
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 +81,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,
|
||||
@@ -95,6 +110,22 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDef
|
||||
},
|
||||
shadow: null,
|
||||
},
|
||||
"dual-frame": {
|
||||
label: "Dual Frame",
|
||||
transform: {
|
||||
type: "split",
|
||||
gapFraction: 0.02,
|
||||
minGap: 12,
|
||||
screenUnits: 2,
|
||||
webcamUnits: 1,
|
||||
},
|
||||
borderRadius: {
|
||||
max: MAX_BORDER_RADIUS,
|
||||
min: 12,
|
||||
fraction: 0.06,
|
||||
},
|
||||
shadow: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const WEBCAM_LAYOUT_PRESETS = Object.entries(WEBCAM_LAYOUT_PRESET_MAP).map(
|
||||
@@ -125,6 +156,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 +166,7 @@ export function computeCompositeLayout(params: {
|
||||
screenSize,
|
||||
webcamSize,
|
||||
layoutPreset = "picture-in-picture",
|
||||
webcamSizePreset = 25,
|
||||
webcamPosition,
|
||||
webcamMaskShape = "rectangle",
|
||||
} = params;
|
||||
@@ -143,6 +176,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;
|
||||
}
|
||||
@@ -183,6 +218,69 @@ export function computeCompositeLayout(params: {
|
||||
};
|
||||
}
|
||||
|
||||
if (preset.transform.type === "split") {
|
||||
const screenRect = centerRect({
|
||||
canvasSize,
|
||||
size: screenSize,
|
||||
maxSize: maxContentSize,
|
||||
});
|
||||
|
||||
if (!webcamWidth || !webcamHeight || webcamWidth <= 0 || webcamHeight <= 0) {
|
||||
return { screenRect, webcamRect: null };
|
||||
}
|
||||
|
||||
const contentWidth = Math.min(canvasWidth, Math.max(1, Math.round(maxContentSize.width)));
|
||||
const contentHeight = Math.min(canvasHeight, Math.max(1, Math.round(maxContentSize.height)));
|
||||
const contentX = Math.max(0, Math.floor((canvasWidth - contentWidth) / 2));
|
||||
const contentY = Math.max(0, Math.floor((canvasHeight - contentHeight) / 2));
|
||||
const gap = Math.max(
|
||||
preset.transform.minGap,
|
||||
Math.round(contentWidth * preset.transform.gapFraction),
|
||||
);
|
||||
const totalUnits = preset.transform.screenUnits + preset.transform.webcamUnits;
|
||||
const availableWidth = Math.max(1, contentWidth - gap);
|
||||
const screenSlotWidth = Math.max(
|
||||
1,
|
||||
Math.round((availableWidth * preset.transform.screenUnits) / totalUnits),
|
||||
);
|
||||
const webcamSlotWidth = Math.max(1, availableWidth - screenSlotWidth);
|
||||
|
||||
const screenSlot = {
|
||||
x: contentX,
|
||||
y: contentY,
|
||||
width: screenSlotWidth,
|
||||
height: contentHeight,
|
||||
};
|
||||
const webcamSlot = {
|
||||
x: contentX + screenSlotWidth + gap,
|
||||
y: contentY,
|
||||
width: webcamSlotWidth,
|
||||
height: contentHeight,
|
||||
};
|
||||
|
||||
const webcamBorderRadius = Math.min(
|
||||
preset.borderRadius.max,
|
||||
Math.max(
|
||||
preset.borderRadius.min,
|
||||
Math.round(Math.min(webcamSlot.width, webcamSlot.height) * preset.borderRadius.fraction),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
screenRect: screenSlot,
|
||||
screenBorderRadius: webcamBorderRadius,
|
||||
webcamRect: {
|
||||
x: webcamSlot.x,
|
||||
y: webcamSlot.y,
|
||||
width: webcamSlot.width,
|
||||
height: webcamSlot.height,
|
||||
borderRadius: webcamBorderRadius,
|
||||
maskShape: "rectangle",
|
||||
},
|
||||
screenCover: true,
|
||||
};
|
||||
}
|
||||
|
||||
const transform = preset.transform;
|
||||
const screenRect = centerRect({
|
||||
canvasSize,
|
||||
@@ -198,8 +296,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);
|
||||
@@ -258,7 +359,16 @@ export function computeCompositeLayout(params: {
|
||||
|
||||
function centerRect(params: { canvasSize: Size; size: Size; maxSize: Size }): RenderRect {
|
||||
const { canvasSize, size, maxSize } = params;
|
||||
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
||||
return centerRectInBounds({
|
||||
bounds: { x: 0, y: 0, width: canvasSize.width, height: canvasSize.height },
|
||||
size,
|
||||
maxSize,
|
||||
});
|
||||
}
|
||||
|
||||
function centerRectInBounds(params: { bounds: RenderRect; size: Size; maxSize: Size }): RenderRect {
|
||||
const { bounds, size, maxSize } = params;
|
||||
const { x: boundsX, y: boundsY, width: boundsWidth, height: boundsHeight } = bounds;
|
||||
const { width, height } = size;
|
||||
const { width: maxWidth, height: maxHeight } = maxSize;
|
||||
const scale = Math.min(maxWidth / width, maxHeight / height, 1);
|
||||
@@ -266,8 +376,8 @@ function centerRect(params: { canvasSize: Size; size: Size; maxSize: Size }): Re
|
||||
const resolvedHeight = Math.round(height * scale);
|
||||
|
||||
return {
|
||||
x: Math.max(0, Math.floor((canvasWidth - resolvedWidth) / 2)),
|
||||
y: Math.max(0, Math.floor((canvasHeight - resolvedHeight) / 2)),
|
||||
x: boundsX + Math.max(0, Math.floor((boundsWidth - resolvedWidth) / 2)),
|
||||
y: boundsY + Math.max(0, Math.floor((boundsHeight - resolvedHeight) / 2)),
|
||||
width: resolvedWidth,
|
||||
height: resolvedHeight,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,47 @@
|
||||
import type { AnnotationRegion, ArrowDirection } from "@/components/video-editor/types";
|
||||
import { type AnnotationRegion, type ArrowDirection } from "@/components/video-editor/types";
|
||||
import {
|
||||
applyMosaicToImageData,
|
||||
getBlurOverlayColor,
|
||||
getNormalizedBlurIntensity,
|
||||
getNormalizedMosaicBlockSize,
|
||||
normalizeBlurType,
|
||||
} from "@/lib/blurEffects";
|
||||
|
||||
let blurScratchCanvas: HTMLCanvasElement | null = null;
|
||||
let blurScratchCtx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
// Matches a single code point whose script is Han (including non-BMP
|
||||
// Extension A-F), Hiragana, Katakana (including halfwidth forms), or
|
||||
// Hangul. Used to split CJK text at character boundaries during wrap,
|
||||
// since CJK scripts have no word-separating whitespace. Unicode script
|
||||
// property escapes require ES2018+; tsconfig target is ES2020.
|
||||
const CJK_CHAR = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
||||
|
||||
function tokenizeForWrap(line: string): string[] {
|
||||
// Split Latin text on whitespace (preserving the whitespace as its own token,
|
||||
// matching the original behavior), and split CJK runs into individual
|
||||
// characters so each one becomes a breakable unit. This mirrors the editor's
|
||||
// CSS `word-break: break-word` handling for CJK content.
|
||||
const tokens: string[] = [];
|
||||
let buffer = "";
|
||||
const chars = Array.from(line);
|
||||
const flushBuffer = () => {
|
||||
if (buffer) {
|
||||
tokens.push(...buffer.split(/(\s+)/).filter((s) => s.length > 0));
|
||||
buffer = "";
|
||||
}
|
||||
};
|
||||
for (const ch of chars) {
|
||||
if (CJK_CHAR.test(ch)) {
|
||||
flushBuffer();
|
||||
tokens.push(ch);
|
||||
} else {
|
||||
buffer += ch;
|
||||
}
|
||||
}
|
||||
flushBuffer();
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// SVG path data for each arrow direction
|
||||
const ARROW_PATHS: Record<ArrowDirection, string[]> = {
|
||||
@@ -96,6 +139,101 @@ function renderArrow(
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawBlurPath(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotation: AnnotationRegion,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const shape = annotation.blurData?.shape || "rectangle";
|
||||
if (shape === "rectangle") {
|
||||
ctx.beginPath();
|
||||
ctx.rect(x, y, width, height);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shape === "oval") {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const points = annotation.blurData?.freehandPoints;
|
||||
if (shape === "freehand" && points && points.length >= 3) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + (points[0].x / 100) * width, y + (points[0].y / 100) * height);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
ctx.lineTo(x + (points[i].x / 100) * width, y + (points[i].y / 100) * height);
|
||||
}
|
||||
ctx.closePath();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.rect(x, y, width, height);
|
||||
}
|
||||
|
||||
function renderBlur(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotation: AnnotationRegion,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
scaleFactor: number,
|
||||
) {
|
||||
const canvas = ctx.canvas;
|
||||
const blurType = normalizeBlurType(annotation.blurData?.type);
|
||||
|
||||
const blurRadius = Math.max(
|
||||
1,
|
||||
Math.round(getNormalizedBlurIntensity(annotation.blurData) * scaleFactor),
|
||||
);
|
||||
const samplePadding =
|
||||
blurType === "mosaic"
|
||||
? Math.max(0, Math.ceil(getNormalizedMosaicBlockSize(annotation.blurData, scaleFactor)))
|
||||
: Math.max(2, Math.ceil(blurRadius * 2));
|
||||
const sx = Math.max(0, Math.floor(x) - samplePadding);
|
||||
const sy = Math.max(0, Math.floor(y) - samplePadding);
|
||||
const ex = Math.min(canvas.width, Math.ceil(x + width) + samplePadding);
|
||||
const ey = Math.min(canvas.height, Math.ceil(y + height) + samplePadding);
|
||||
const sw = Math.max(0, ex - sx);
|
||||
const sh = Math.max(0, ey - sy);
|
||||
if (sw <= 0 || sh <= 0) return;
|
||||
|
||||
if (!blurScratchCanvas || !blurScratchCtx) {
|
||||
blurScratchCanvas = document.createElement("canvas");
|
||||
blurScratchCtx = blurScratchCanvas.getContext("2d");
|
||||
}
|
||||
if (!blurScratchCanvas || !blurScratchCtx) return;
|
||||
|
||||
blurScratchCanvas.width = sw;
|
||||
blurScratchCanvas.height = sh;
|
||||
blurScratchCtx.clearRect(0, 0, sw, sh);
|
||||
blurScratchCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);
|
||||
|
||||
if (blurType === "mosaic") {
|
||||
const imageData = blurScratchCtx.getImageData(0, 0, sw, sh);
|
||||
applyMosaicToImageData(
|
||||
imageData,
|
||||
getNormalizedMosaicBlockSize(annotation.blurData, scaleFactor),
|
||||
);
|
||||
blurScratchCtx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
drawBlurPath(ctx, annotation, x, y, width, height);
|
||||
ctx.clip();
|
||||
ctx.filter = blurType === "mosaic" ? "none" : `blur(${blurRadius}px)`;
|
||||
ctx.drawImage(blurScratchCanvas, sx, sy);
|
||||
ctx.filter = "none";
|
||||
ctx.fillStyle = getBlurOverlayColor(annotation.blurData);
|
||||
ctx.fillRect(sx, sy, sw, sh);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function renderText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotation: AnnotationRegion,
|
||||
@@ -144,13 +282,13 @@ function renderText(
|
||||
lines.push("");
|
||||
continue;
|
||||
}
|
||||
const words = rawLine.split(/(\s+)/);
|
||||
const tokens = tokenizeForWrap(rawLine);
|
||||
let current = "";
|
||||
for (const word of words) {
|
||||
const test = current + word;
|
||||
for (const token of tokens) {
|
||||
const test = current + token;
|
||||
if (current && ctx.measureText(test).width > availableWidth) {
|
||||
lines.push(current);
|
||||
current = word.trimStart();
|
||||
current = token.trimStart();
|
||||
} else {
|
||||
current = test;
|
||||
}
|
||||
@@ -268,7 +406,7 @@ export async function renderAnnotations(
|
||||
): Promise<void> {
|
||||
// Filter active annotations at current time
|
||||
const activeAnnotations = annotations.filter(
|
||||
(ann) => currentTimeMs >= ann.startMs && currentTimeMs <= ann.endMs,
|
||||
(ann) => currentTimeMs >= ann.startMs && currentTimeMs < ann.endMs,
|
||||
);
|
||||
|
||||
// Sort by z-index (lower first, so higher z-index draws on top)
|
||||
@@ -304,6 +442,10 @@ export async function renderAnnotations(
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "blur":
|
||||
renderBlur(ctx, annotation, x, y, width, height, scaleFactor);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { VideoMuxer } from "./muxer";
|
||||
const AUDIO_BITRATE = 128_000;
|
||||
const DECODE_BACKPRESSURE_LIMIT = 20;
|
||||
const MIN_SPEED_REGION_DELTA_MS = 0.0001;
|
||||
const SEEK_TIMEOUT_MS = 5_000;
|
||||
|
||||
export class AudioProcessor {
|
||||
private cancelled = false;
|
||||
@@ -18,9 +19,9 @@ export class AudioProcessor {
|
||||
demuxer: WebDemuxer,
|
||||
muxer: VideoMuxer,
|
||||
videoUrl: string,
|
||||
trimRegions?: TrimRegion[],
|
||||
speedRegions?: SpeedRegion[],
|
||||
readEndSec?: number,
|
||||
trimRegions: TrimRegion[] | undefined,
|
||||
speedRegions: SpeedRegion[] | undefined,
|
||||
validatedDurationSec: number,
|
||||
): Promise<void> {
|
||||
const sortedTrims = trimRegions ? [...trimRegions].sort((a, b) => a.startMs - b.startMs) : [];
|
||||
const sortedSpeedRegions = speedRegions
|
||||
@@ -35,14 +36,19 @@ export class AudioProcessor {
|
||||
videoUrl,
|
||||
sortedTrims,
|
||||
sortedSpeedRegions,
|
||||
validatedDurationSec,
|
||||
);
|
||||
if (!this.cancelled) {
|
||||
if (!this.cancelled && renderedAudioBlob.size > 0) {
|
||||
await this.muxRenderedAudioBlob(renderedAudioBlob, muxer);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No speed edits: keep the original demux/decode/encode path with trim timestamp remap.
|
||||
// The +0.5s buffer mirrors streamingDecoder.decodeAll's read window so the trim-only
|
||||
// and speed-aware paths agree on how far to read past the validated duration boundary.
|
||||
const readEndSec = validatedDurationSec + 0.5;
|
||||
await this.processTrimOnlyAudio(demuxer, muxer, sortedTrims, readEndSec);
|
||||
}
|
||||
|
||||
@@ -55,7 +61,7 @@ export class AudioProcessor {
|
||||
): Promise<void> {
|
||||
let audioConfig: AudioDecoderConfig;
|
||||
try {
|
||||
audioConfig = (await demuxer.getDecoderConfig("audio")) as AudioDecoderConfig;
|
||||
audioConfig = await demuxer.getDecoderConfig("audio");
|
||||
} catch {
|
||||
console.warn("[AudioProcessor] No audio track found, skipping");
|
||||
return;
|
||||
@@ -80,11 +86,10 @@ export class AudioProcessor {
|
||||
typeof readEndSec === "number" && Number.isFinite(readEndSec)
|
||||
? Math.max(0, readEndSec)
|
||||
: undefined;
|
||||
const audioStream = (
|
||||
const audioStream =
|
||||
safeReadEndSec !== undefined
|
||||
? demuxer.read("audio", 0, safeReadEndSec)
|
||||
: demuxer.read("audio")
|
||||
) as ReadableStream<EncodedAudioChunk>;
|
||||
: demuxer.read("audio");
|
||||
const reader = audioStream.getReader();
|
||||
|
||||
try {
|
||||
@@ -187,6 +192,7 @@ export class AudioProcessor {
|
||||
videoUrl: string,
|
||||
trimRegions: TrimRegion[],
|
||||
speedRegions: SpeedRegion[],
|
||||
validatedDurationSec: number,
|
||||
): Promise<Blob> {
|
||||
const media = document.createElement("audio");
|
||||
media.src = videoUrl;
|
||||
@@ -211,15 +217,44 @@ export class AudioProcessor {
|
||||
const destinationNode = audioContext.createMediaStreamDestination();
|
||||
sourceNode.connect(destinationNode);
|
||||
|
||||
const { recorder, recordedBlobPromise } = this.startAudioRecording(destinationNode.stream);
|
||||
let rafId: number | null = null;
|
||||
let recorder: MediaRecorder | null = null;
|
||||
let recordedBlobPromise: Promise<Blob> | null = null;
|
||||
|
||||
try {
|
||||
if (audioContext.state === "suspended") {
|
||||
await audioContext.resume();
|
||||
}
|
||||
|
||||
await this.seekTo(media, 0);
|
||||
// Skip past any initial trim region(s) before recording starts to avoid
|
||||
// capturing trimmed audio during the first rAF frames of playback.
|
||||
// Loops to handle back-to-back or overlapping trims at t=0.
|
||||
const effectiveEnd = validatedDurationSec;
|
||||
let startPosition = 0;
|
||||
for (let i = 0; i <= trimRegions.length; i++) {
|
||||
const activeTrim = this.findActiveTrimRegion(startPosition * 1000, trimRegions);
|
||||
if (!activeTrim) break;
|
||||
startPosition = activeTrim.endMs / 1000;
|
||||
if (startPosition >= effectiveEnd) break;
|
||||
}
|
||||
|
||||
if (startPosition >= effectiveEnd) {
|
||||
// All content is trimmed — return silent blob
|
||||
return new Blob([], { type: "audio/webm" });
|
||||
}
|
||||
|
||||
await this.seekTo(media, startPosition);
|
||||
|
||||
// Set initial playback rate for the starting position
|
||||
const initialSpeedRegion = this.findActiveSpeedRegion(startPosition * 1000, speedRegions);
|
||||
if (initialSpeedRegion) {
|
||||
media.playbackRate = initialSpeedRegion.speed;
|
||||
}
|
||||
|
||||
// Start recording only AFTER seeking past trims
|
||||
const recording = this.startAudioRecording(destinationNode.stream);
|
||||
recorder = recording.recorder;
|
||||
recordedBlobPromise = recording.recordedBlobPromise;
|
||||
await media.play();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -249,24 +284,66 @@ export class AudioProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop playback at validated duration — browser's media.duration
|
||||
// may be inflated from bad container metadata.
|
||||
if (media.currentTime >= validatedDurationSec) {
|
||||
media.pause();
|
||||
cleanup();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTimeMs = media.currentTime * 1000;
|
||||
const activeTrimRegion = this.findActiveTrimRegion(currentTimeMs, trimRegions);
|
||||
|
||||
if (activeTrimRegion && !media.paused && !media.ended) {
|
||||
const skipToTime = activeTrimRegion.endMs / 1000;
|
||||
if (skipToTime >= media.duration) {
|
||||
if (skipToTime >= media.duration || skipToTime >= validatedDurationSec) {
|
||||
media.pause();
|
||||
cleanup();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
// Pause recording during trim seek to prevent capturing
|
||||
// silence/noise as the audio element seeks.
|
||||
media.pause();
|
||||
if (recorder?.state === "recording") recorder.pause();
|
||||
const onSeeked = () => {
|
||||
clearTimeout(seekTimer);
|
||||
if (this.cancelled) {
|
||||
cleanup();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (recorder?.state === "paused") recorder.resume();
|
||||
media
|
||||
.play()
|
||||
.then(() => {
|
||||
if (!this.cancelled) rafId = requestAnimationFrame(tick);
|
||||
})
|
||||
.catch((err) => {
|
||||
cleanup();
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to resume playback after trim seek: ${err instanceof Error ? err.message : String(err)}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
const seekTimer = window.setTimeout(() => {
|
||||
media.removeEventListener("seeked", onSeeked);
|
||||
cleanup();
|
||||
reject(new Error("Audio seek timed out while skipping trim region"));
|
||||
}, SEEK_TIMEOUT_MS);
|
||||
media.addEventListener("seeked", onSeeked, { once: true });
|
||||
media.currentTime = skipToTime;
|
||||
} else {
|
||||
const activeSpeedRegion = this.findActiveSpeedRegion(currentTimeMs, speedRegions);
|
||||
const playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
|
||||
if (Math.abs(media.playbackRate - playbackRate) > 0.0001) {
|
||||
media.playbackRate = playbackRate;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSpeedRegion = this.findActiveSpeedRegion(currentTimeMs, speedRegions);
|
||||
const playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
|
||||
if (Math.abs(media.playbackRate - playbackRate) > 0.0001) {
|
||||
media.playbackRate = playbackRate;
|
||||
}
|
||||
|
||||
if (!media.paused && !media.ended) {
|
||||
@@ -286,7 +363,7 @@ export class AudioProcessor {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
media.pause();
|
||||
if (recorder.state !== "inactive") {
|
||||
if (recorder && recorder.state !== "inactive") {
|
||||
recorder.stop();
|
||||
}
|
||||
destinationNode.stream.getTracks().forEach((track) => track.stop());
|
||||
@@ -297,6 +374,12 @@ export class AudioProcessor {
|
||||
media.load();
|
||||
}
|
||||
|
||||
if (!recordedBlobPromise) {
|
||||
// Invariant: either an early return above fires, or startAudioRecording ran and
|
||||
// populated recordedBlobPromise before the playback Promise resolved. Reaching
|
||||
// here means that contract was broken — fail loud instead of returning silence.
|
||||
throw new Error("Audio recorder finished without assigning recordedBlobPromise");
|
||||
}
|
||||
const recordedBlob = await recordedBlobPromise;
|
||||
if (this.cancelled) {
|
||||
throw new Error("Export cancelled");
|
||||
@@ -314,8 +397,8 @@ export class AudioProcessor {
|
||||
|
||||
try {
|
||||
await demuxer.load(file);
|
||||
const audioConfig = (await demuxer.getDecoderConfig("audio")) as AudioDecoderConfig;
|
||||
const reader = (demuxer.read("audio") as ReadableStream<EncodedAudioChunk>).getReader();
|
||||
const audioConfig = await demuxer.getDecoderConfig("audio");
|
||||
const reader = demuxer.read("audio").getReader();
|
||||
let isFirstChunk = true;
|
||||
|
||||
try {
|
||||
@@ -459,7 +542,10 @@ export class AudioProcessor {
|
||||
}
|
||||
|
||||
private cloneWithTimestamp(src: AudioData, newTimestamp: number): AudioData {
|
||||
const isPlanar = src.format?.includes("planar") ?? false;
|
||||
if (!src.format) {
|
||||
throw new Error("AudioData format is required for cloning");
|
||||
}
|
||||
const isPlanar = src.format.includes("planar");
|
||||
const numPlanes = isPlanar ? src.numberOfChannels : 1;
|
||||
|
||||
let totalSize = 0;
|
||||
@@ -476,7 +562,7 @@ export class AudioProcessor {
|
||||
}
|
||||
|
||||
return new AudioData({
|
||||
format: src.format!,
|
||||
format: src.format,
|
||||
sampleRate: src.sampleRate,
|
||||
numberOfFrames: src.numberOfFrames,
|
||||
numberOfChannels: src.numberOfChannels,
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
CropRegion,
|
||||
SpeedRegion,
|
||||
WebcamLayoutPreset,
|
||||
WebcamSizePreset,
|
||||
ZoomDepth,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
@@ -70,12 +71,14 @@ 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[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
|
||||
platform: string;
|
||||
}
|
||||
|
||||
interface AnimationState {
|
||||
@@ -112,6 +115,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;
|
||||
@@ -120,9 +125,11 @@ export class FrameRenderer {
|
||||
private smoothedAutoFocus: { cx: number; cy: number } | null = null;
|
||||
private prevAnimationTimeMs: number | null = null;
|
||||
private prevTargetProgress = 0;
|
||||
private isLinux = false;
|
||||
|
||||
constructor(config: FrameRenderConfig) {
|
||||
this.config = config;
|
||||
this.isLinux = config.platform === "linux";
|
||||
this.animationState = {
|
||||
scale: 1,
|
||||
focusX: DEFAULT_FOCUS.cx,
|
||||
@@ -183,14 +190,24 @@ export class FrameRenderer {
|
||||
this.compositeCanvas = document.createElement("canvas");
|
||||
this.compositeCanvas.width = this.config.width;
|
||||
this.compositeCanvas.height = this.config.height;
|
||||
|
||||
// On Linux, getImageData() is called frequently causing frequent CPU readback
|
||||
this.compositeCtx = this.compositeCanvas.getContext("2d", {
|
||||
willReadFrequently: false,
|
||||
willReadFrequently: this.isLinux,
|
||||
});
|
||||
|
||||
if (!this.compositeCtx) {
|
||||
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 +470,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,
|
||||
});
|
||||
@@ -494,7 +512,12 @@ export class FrameRenderer {
|
||||
const previewWidth = this.config.previewWidth ?? this.config.width;
|
||||
const previewHeight = this.config.previewHeight ?? this.config.height;
|
||||
const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight);
|
||||
const scaledBorderRadius = compositeLayout.screenCover ? 0 : borderRadius * canvasScaleFactor;
|
||||
const scaledBorderRadius =
|
||||
compositeLayout.screenBorderRadius != null
|
||||
? compositeLayout.screenBorderRadius
|
||||
: compositeLayout.screenCover
|
||||
? 0
|
||||
: borderRadius * canvasScaleFactor;
|
||||
|
||||
this.maskGraphics.clear();
|
||||
this.maskGraphics.roundRect(0, 0, screenRect.width, screenRect.height, scaledBorderRadius);
|
||||
@@ -522,16 +545,10 @@ export class FrameRenderer {
|
||||
private updateAnimationState(timeMs: number): number {
|
||||
if (!this.cameraContainer || !this.layoutCache) return 0;
|
||||
|
||||
const bmEx = this.layoutCache.maskRect;
|
||||
const ssEx = this.layoutCache.stageSize;
|
||||
const viewportRatio =
|
||||
bmEx.width > 0 && bmEx.height > 0
|
||||
? { widthRatio: ssEx.width / bmEx.width, heightRatio: ssEx.height / bmEx.height }
|
||||
: undefined;
|
||||
const { region, strength, blendedScale, transition } = findDominantRegion(
|
||||
this.config.zoomRegions,
|
||||
timeMs,
|
||||
{ connectZooms: true, cursorTelemetry: this.config.cursorTelemetry, viewportRatio },
|
||||
{ connectZooms: true, cursorTelemetry: this.config.cursorTelemetry },
|
||||
);
|
||||
|
||||
const defaultFocus = DEFAULT_FOCUS;
|
||||
@@ -675,10 +692,49 @@ 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.isLinux
|
||||
? this.readbackVideoCanvas()
|
||||
: (this.app.canvas as HTMLCanvasElement);
|
||||
|
||||
const ctx = this.compositeCtx;
|
||||
const w = this.compositeCanvas.width;
|
||||
const h = this.compositeCanvas.height;
|
||||
@@ -735,6 +791,22 @@ export class FrameRenderer {
|
||||
if (webcamFrame && webcamRect) {
|
||||
const preset = getWebcamLayoutPresetDefinition(this.config.webcamLayoutPreset);
|
||||
const shape = webcamRect.maskShape ?? this.config.webcamMaskShape ?? "rectangle";
|
||||
const sourceWidth =
|
||||
("displayWidth" in webcamFrame && webcamFrame.displayWidth > 0
|
||||
? webcamFrame.displayWidth
|
||||
: webcamFrame.codedWidth) || webcamRect.width;
|
||||
const sourceHeight =
|
||||
("displayHeight" in webcamFrame && webcamFrame.displayHeight > 0
|
||||
? webcamFrame.displayHeight
|
||||
: webcamFrame.codedHeight) || webcamRect.height;
|
||||
const sourceAspect = sourceWidth / sourceHeight;
|
||||
const targetAspect = webcamRect.width / webcamRect.height;
|
||||
const sourceCropWidth =
|
||||
sourceAspect > targetAspect ? Math.round(sourceHeight * targetAspect) : sourceWidth;
|
||||
const sourceCropHeight =
|
||||
sourceAspect > targetAspect ? sourceHeight : Math.round(sourceWidth / targetAspect);
|
||||
const sourceCropX = Math.max(0, Math.round((sourceWidth - sourceCropWidth) / 2));
|
||||
const sourceCropY = Math.max(0, Math.round((sourceHeight - sourceCropHeight) / 2));
|
||||
ctx.save();
|
||||
drawCanvasClipPath(
|
||||
ctx,
|
||||
@@ -756,6 +828,10 @@ export class FrameRenderer {
|
||||
ctx.clip();
|
||||
ctx.drawImage(
|
||||
webcamFrame as unknown as CanvasImageSource,
|
||||
sourceCropX,
|
||||
sourceCropY,
|
||||
sourceCropWidth,
|
||||
sourceCropHeight,
|
||||
webcamRect.x,
|
||||
webcamRect.y,
|
||||
webcamRect.width,
|
||||
@@ -795,5 +871,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,8 +5,10 @@ import type {
|
||||
SpeedRegion,
|
||||
TrimRegion,
|
||||
WebcamLayoutPreset,
|
||||
WebcamSizePreset,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import { getPlatform } from "@/utils/platformUtils";
|
||||
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||
import { FrameRenderer } from "./frameRenderer";
|
||||
import { StreamingVideoDecoder } from "./streamingDecoder";
|
||||
@@ -42,6 +44,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;
|
||||
@@ -113,7 +116,10 @@ export class GifExporter {
|
||||
|
||||
async export(): Promise<ExportResult> {
|
||||
let webcamFrameQueue: AsyncVideoFrameQueue | null = null;
|
||||
|
||||
try {
|
||||
const platform = await getPlatform();
|
||||
|
||||
this.cleanup();
|
||||
this.cancelled = false;
|
||||
|
||||
@@ -144,12 +150,14 @@ 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,
|
||||
previewWidth: this.config.previewWidth,
|
||||
previewHeight: this.config.previewHeight,
|
||||
cursorTelemetry: this.config.cursorTelemetry,
|
||||
platform,
|
||||
});
|
||||
await this.renderer.initialize();
|
||||
|
||||
@@ -171,11 +179,11 @@ export class GifExporter {
|
||||
});
|
||||
|
||||
// Calculate effective duration and frame count (excluding trim regions)
|
||||
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(
|
||||
const { effectiveDuration, totalFrames } = this.streamingDecoder.getExportMetrics(
|
||||
this.config.frameRate,
|
||||
this.config.trimRegions,
|
||||
this.config.speedRegions,
|
||||
);
|
||||
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
|
||||
|
||||
// Calculate frame delay in milliseconds (gif.js uses ms)
|
||||
const frameDelay = Math.round(1000 / this.config.frameRate);
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldFailDecodeEndedEarly } from "./streamingDecoder";
|
||||
import { shouldFailDecodeEndedEarly, validateDuration } from "./streamingDecoder";
|
||||
|
||||
describe("validateDuration", () => {
|
||||
it("returns scanned duration when container reports Infinity", () => {
|
||||
expect(validateDuration(Infinity, 15.3)).toBe(15.3);
|
||||
});
|
||||
|
||||
it("returns scanned duration when container reports 0", () => {
|
||||
expect(validateDuration(0, 15.3)).toBe(15.3);
|
||||
});
|
||||
|
||||
it("returns scanned duration when container reports NaN", () => {
|
||||
expect(validateDuration(NaN, 15.3)).toBe(15.3);
|
||||
});
|
||||
|
||||
it("returns scanned duration when container is inflated beyond threshold", () => {
|
||||
expect(validateDuration(42, 15.3)).toBe(15.3);
|
||||
});
|
||||
|
||||
it("returns container duration when values are close", () => {
|
||||
expect(validateDuration(15.5, 15.3)).toBe(15.5);
|
||||
});
|
||||
|
||||
it("returns container duration when scanned is slightly higher", () => {
|
||||
// container < scanned (scanned overshoot from last frame duration)
|
||||
expect(validateDuration(15.0, 15.3)).toBe(15.0);
|
||||
});
|
||||
|
||||
it("returns scanned duration when container under-reports beyond threshold", () => {
|
||||
expect(validateDuration(10, 15.3)).toBe(15.3);
|
||||
});
|
||||
|
||||
it("returns container duration when scanned is zero (corrupted/empty file)", () => {
|
||||
expect(validateDuration(10, 0)).toBe(10);
|
||||
});
|
||||
|
||||
it("returns 0 when both container is NaN and scanned is zero", () => {
|
||||
expect(validateDuration(NaN, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldFailDecodeEndedEarly", () => {
|
||||
it("does not fail once every segment has been satisfied", () => {
|
||||
|
||||
@@ -2,6 +2,52 @@ import { WebDemuxer } from "web-demuxer";
|
||||
import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types";
|
||||
|
||||
const SOURCE_LOAD_TIMEOUT_MS = 60_000;
|
||||
const EPSILON_SEC = 0.001;
|
||||
/**
|
||||
* 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;
|
||||
@@ -24,6 +70,37 @@ type EarlyDecodeEndCheck = {
|
||||
const EARLY_DECODE_END_THRESHOLD_SEC = 1;
|
||||
const METADATA_TAIL_TOLERANCE_SEC = 1.5;
|
||||
const STREAM_DURATION_MATCH_TOLERANCE_SEC = 0.25;
|
||||
const DURATION_DIVERGENCE_THRESHOLD_SEC = 1.5;
|
||||
// Fallback upper bound for the packet scan when no reliable duration hint is
|
||||
// available. Explicit end is required (some containers are truncated without
|
||||
// one), but the hint-derived bound would cap the scan prematurely when
|
||||
// container/stream duration are missing or corrupt.
|
||||
const SCAN_UNBOUNDED_FALLBACK_SEC = 24 * 60 * 60;
|
||||
|
||||
/**
|
||||
* Validate container duration against actual packet timestamps.
|
||||
*
|
||||
* Chrome/Electron's MediaRecorder writes WebM containers with unreliable
|
||||
* Duration fields (often Infinity, 0, or inflated) — especially on Linux.
|
||||
* This function picks the most trustworthy duration value.
|
||||
*
|
||||
* @param containerDuration Duration from the container-level metadata
|
||||
* @param scannedDuration Duration derived from actual packet timestamps (ground truth)
|
||||
*/
|
||||
export function validateDuration(containerDuration: number, scannedDuration: number): number {
|
||||
if (scannedDuration <= 0) {
|
||||
// Zero scanned duration means corrupted/empty file — fall back to container
|
||||
// (downstream shouldFailDecodeEndedEarly will catch truly empty files)
|
||||
return Number.isFinite(containerDuration) ? Math.max(containerDuration, 0) : 0;
|
||||
}
|
||||
if (!Number.isFinite(containerDuration) || containerDuration <= 0) {
|
||||
return scannedDuration;
|
||||
}
|
||||
if (Math.abs(containerDuration - scannedDuration) > DURATION_DIVERGENCE_THRESHOLD_SEC) {
|
||||
return scannedDuration;
|
||||
}
|
||||
return containerDuration;
|
||||
}
|
||||
|
||||
export function shouldFailDecodeEndedEarly({
|
||||
cancelled,
|
||||
@@ -155,10 +232,43 @@ export class StreamingVideoDecoder {
|
||||
|
||||
const audioStream = mediaInfo.streams.find((s) => s.codec_type_string === "audio");
|
||||
|
||||
// Scan video packets to find the true content boundary.
|
||||
// MediaRecorder (especially on Linux) writes unreliable container durations.
|
||||
// Packet timestamps are ground truth — no decode needed, just timestamp reads.
|
||||
// Pass explicit range because some containers are truncated without one.
|
||||
// Sanitize because mediaInfo.duration can be NaN/Infinity (Chromium Linux bug),
|
||||
// which would propagate into demuxer.read() as an invalid endpoint.
|
||||
const containerDurationSec = Number.isFinite(mediaInfo.duration) ? mediaInfo.duration : 0;
|
||||
const streamDurationSec =
|
||||
typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration)
|
||||
? videoStream.duration
|
||||
: 0;
|
||||
const hintedDurationSec = Math.max(containerDurationSec, streamDurationSec, 0);
|
||||
const scanEndSec =
|
||||
hintedDurationSec > 0 ? hintedDurationSec + 0.5 : SCAN_UNBOUNDED_FALLBACK_SEC;
|
||||
let maxPacketEndUs = 0;
|
||||
const scanReader = this.demuxer.read("video", 0, scanEndSec).getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await scanReader.read();
|
||||
if (done || !value) break;
|
||||
const endUs = value.timestamp + (value.duration ?? 0);
|
||||
if (endUs > maxPacketEndUs) maxPacketEndUs = endUs;
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await scanReader.cancel();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
const scannedDuration = maxPacketEndUs / 1_000_000;
|
||||
const validatedDuration = validateDuration(mediaInfo.duration, scannedDuration);
|
||||
|
||||
this.metadata = {
|
||||
width: videoStream?.width || 1920,
|
||||
height: videoStream?.height || 1080,
|
||||
duration: mediaInfo.duration,
|
||||
duration: validatedDuration,
|
||||
streamDuration:
|
||||
typeof videoStream?.duration === "number" && Number.isFinite(videoStream.duration)
|
||||
? videoStream.duration
|
||||
@@ -171,7 +281,15 @@ export class StreamingVideoDecoder {
|
||||
|
||||
return this.metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes all video frames from the loaded source and invokes a callback for each.
|
||||
* Handles trimming and speed adjustments, and resamples to the target frame rate.
|
||||
* On Windows, early decode termination is tolerated to work around driver quirks.
|
||||
* @param targetFrameRate - Desired output frame rate.
|
||||
* @param trimRegions - Array of time regions to keep (others discarded).
|
||||
* @param speedRegions - Array of speed adjustments for specific time ranges.
|
||||
* @param onFrame - Async callback receiving each decoded VideoFrame.
|
||||
*/
|
||||
async decodeAll(
|
||||
targetFrameRate: number,
|
||||
trimRegions: TrimRegion[] | undefined,
|
||||
@@ -183,17 +301,30 @@ 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),
|
||||
speedRegions,
|
||||
);
|
||||
const requiredEndSec = segments[segments.length - 1]?.endSec ?? 0;
|
||||
|
||||
const segmentOutputFrameCounts = segments.map((segment) =>
|
||||
Math.ceil(((segment.endSec - segment.startSec) / segment.speed) * targetFrameRate),
|
||||
Math.ceil(
|
||||
((segment.endSec - segment.startSec - EPSILON_SEC) / segment.speed) * targetFrameRate,
|
||||
),
|
||||
);
|
||||
const frameDurationUs = 1_000_000 / targetFrameRate;
|
||||
const epsilonSec = 0.001;
|
||||
|
||||
// Async frame queue — decoder pushes, consumer pulls
|
||||
const pendingFrames: VideoFrame[] = [];
|
||||
@@ -248,7 +379,7 @@ export class StreamingVideoDecoder {
|
||||
|
||||
// One forward stream through the whole file.
|
||||
// Pass explicit range because some containers are truncated when no end is provided.
|
||||
const readEndSec = Math.max(this.metadata.duration, this.metadata.streamDuration ?? 0) + 0.5;
|
||||
const readEndSec = this.metadata.duration + 0.5;
|
||||
const reader = this.demuxer.read("video", 0, readEndSec).getReader();
|
||||
|
||||
// Feed chunks to decoder in background with backpressure
|
||||
@@ -304,7 +435,7 @@ export class StreamingVideoDecoder {
|
||||
|
||||
const sourceTimeSec =
|
||||
segment.startSec + (segmentFrameIndex / targetFrameRate) * segment.speed;
|
||||
if (sourceTimeSec >= segment.endSec - epsilonSec) return false;
|
||||
if (sourceTimeSec >= segment.endSec - EPSILON_SEC) return false;
|
||||
|
||||
const clone = new VideoFrame(heldFrame, { timestamp: heldFrame.timestamp });
|
||||
await onFrame(clone, exportFrameIndex * frameDurationUs, sourceTimeSec * 1000);
|
||||
@@ -323,7 +454,7 @@ export class StreamingVideoDecoder {
|
||||
// Finalize completed segments before handling this frame.
|
||||
while (
|
||||
segmentIdx < segments.length &&
|
||||
frameTimeSec >= segments[segmentIdx].endSec - epsilonSec
|
||||
frameTimeSec >= segments[segmentIdx].endSec - EPSILON_SEC
|
||||
) {
|
||||
const segment = segments[segmentIdx];
|
||||
while (!this.cancelled && (await emitHeldFrameForTarget(segment))) {
|
||||
@@ -335,7 +466,7 @@ export class StreamingVideoDecoder {
|
||||
if (
|
||||
heldFrame &&
|
||||
segmentIdx < segments.length &&
|
||||
heldFrameSec < segments[segmentIdx].startSec - epsilonSec
|
||||
heldFrameSec < segments[segmentIdx].startSec - EPSILON_SEC
|
||||
) {
|
||||
heldFrame.close();
|
||||
heldFrame = null;
|
||||
@@ -350,7 +481,7 @@ export class StreamingVideoDecoder {
|
||||
const currentSegment = segments[segmentIdx];
|
||||
|
||||
// Before current segment (trimmed region or pre-roll).
|
||||
if (frameTimeSec < currentSegment.startSec - epsilonSec) {
|
||||
if (frameTimeSec < currentSegment.startSec - EPSILON_SEC) {
|
||||
frame.close();
|
||||
continue;
|
||||
}
|
||||
@@ -371,7 +502,7 @@ export class StreamingVideoDecoder {
|
||||
|
||||
const sourceTimeSec =
|
||||
currentSegment.startSec + (segmentFrameIndex / targetFrameRate) * currentSegment.speed;
|
||||
if (sourceTimeSec >= currentSegment.endSec - epsilonSec) {
|
||||
if (sourceTimeSec >= currentSegment.endSec - EPSILON_SEC) {
|
||||
break;
|
||||
}
|
||||
if (sourceTimeSec > handoffBoundarySec) {
|
||||
@@ -393,7 +524,7 @@ export class StreamingVideoDecoder {
|
||||
if (heldFrame && segmentIdx < segments.length) {
|
||||
while (!this.cancelled && segmentIdx < segments.length) {
|
||||
const segment = segments[segmentIdx];
|
||||
if (heldFrameSec < segment.startSec - epsilonSec) {
|
||||
if (heldFrameSec < segment.startSec - EPSILON_SEC) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -405,7 +536,7 @@ export class StreamingVideoDecoder {
|
||||
segmentFrameIndex = 0;
|
||||
if (
|
||||
segmentIdx < segments.length &&
|
||||
heldFrameSec < segments[segmentIdx].startSec - epsilonSec
|
||||
heldFrameSec < segments[segmentIdx].startSec - EPSILON_SEC
|
||||
) {
|
||||
break;
|
||||
}
|
||||
@@ -435,7 +566,8 @@ export class StreamingVideoDecoder {
|
||||
}
|
||||
this.decoder = null;
|
||||
|
||||
const requiredEndSec = segments.length > 0 ? segments[segments.length - 1].endSec : 0;
|
||||
const isWindows = typeof navigator !== "undefined" && /Windows/.test(navigator.userAgent);
|
||||
|
||||
if (
|
||||
shouldFailDecodeEndedEarly({
|
||||
cancelled: this.cancelled,
|
||||
@@ -446,9 +578,22 @@ export class StreamingVideoDecoder {
|
||||
) {
|
||||
const decodedAtLabel =
|
||||
lastDecodedFrameSec === null ? "no decoded frame" : `${lastDecodedFrameSec.toFixed(3)}s`;
|
||||
throw new Error(
|
||||
`Video decode ended early at ${decodedAtLabel} (needed ${requiredEndSec.toFixed(3)}s).`,
|
||||
);
|
||||
const decodeGapSec =
|
||||
lastDecodedFrameSec === null ? Infinity : requiredEndSec - lastDecodedFrameSec;
|
||||
|
||||
// On Windows, tolerate a small decode gap: up to 10% of required duration, capped at 3 seconds.
|
||||
const maxToleratedGap = Math.min(3.0, requiredEndSec * 0.1);
|
||||
|
||||
if (isWindows && lastDecodedFrameSec !== null && decodeGapSec <= maxToleratedGap) {
|
||||
console.warn(
|
||||
`[StreamingVideoDecoder] Decode ended early on Windows with a gap of ${decodeGapSec.toFixed(2)}s ` +
|
||||
`(max tolerated: ${maxToleratedGap.toFixed(2)}s) – proceeding anyway.`,
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Video decode ended early at ${decodedAtLabel} (needed ${requiredEndSec.toFixed(3)}s).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,11 +625,24 @@ export class StreamingVideoDecoder {
|
||||
return segments;
|
||||
}
|
||||
|
||||
getEffectiveDuration(trimRegions?: TrimRegion[], speedRegions?: SpeedRegion[]): number {
|
||||
getExportMetrics(
|
||||
targetFrameRate: number,
|
||||
trimRegions?: TrimRegion[],
|
||||
speedRegions?: SpeedRegion[],
|
||||
): { effectiveDuration: number; totalFrames: number } {
|
||||
if (!this.metadata) throw new Error("Must call loadMetadata() first");
|
||||
const trimSegments = this.computeSegments(this.metadata.duration, trimRegions);
|
||||
const speedSegments = this.splitBySpeed(trimSegments, speedRegions);
|
||||
return speedSegments.reduce((sum, seg) => sum + (seg.endSec - seg.startSec) / seg.speed, 0);
|
||||
const segments = this.splitBySpeed(trimSegments, speedRegions);
|
||||
return {
|
||||
effectiveDuration: segments.reduce(
|
||||
(sum, seg) => sum + (seg.endSec - seg.startSec) / seg.speed,
|
||||
0,
|
||||
),
|
||||
totalFrames: segments.reduce((sum, seg) => {
|
||||
const segDur = seg.endSec - seg.startSec - EPSILON_SEC;
|
||||
return sum + Math.max(0, Math.ceil((segDur / seg.speed) * targetFrameRate));
|
||||
}, 0),
|
||||
};
|
||||
}
|
||||
|
||||
private splitBySpeed(
|
||||
|
||||
@@ -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,8 +4,10 @@ import type {
|
||||
SpeedRegion,
|
||||
TrimRegion,
|
||||
WebcamLayoutPreset,
|
||||
WebcamSizePreset,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import { getPlatform } from "@/utils/platformUtils";
|
||||
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
|
||||
import { AudioProcessor } from "./audioEncoder";
|
||||
import { FrameRenderer } from "./frameRenderer";
|
||||
@@ -33,6 +35,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;
|
||||
@@ -110,6 +113,8 @@ export class VideoExporter {
|
||||
this.fatalEncoderError = null;
|
||||
|
||||
try {
|
||||
const platform = await getPlatform();
|
||||
|
||||
const streamingDecoder = new StreamingVideoDecoder();
|
||||
this.streamingDecoder = streamingDecoder;
|
||||
const videoInfo = await streamingDecoder.loadMetadata(this.config.videoUrl);
|
||||
@@ -137,12 +142,14 @@ 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,
|
||||
previewWidth: this.config.previewWidth,
|
||||
previewHeight: this.config.previewHeight,
|
||||
cursorTelemetry: this.config.cursorTelemetry,
|
||||
platform,
|
||||
});
|
||||
this.renderer = renderer;
|
||||
await renderer.initialize();
|
||||
@@ -154,17 +161,11 @@ export class VideoExporter {
|
||||
this.muxer = muxer;
|
||||
await muxer.initialize();
|
||||
|
||||
const effectiveDuration = streamingDecoder.getEffectiveDuration(
|
||||
const { totalFrames } = streamingDecoder.getExportMetrics(
|
||||
this.config.frameRate,
|
||||
this.config.trimRegions,
|
||||
this.config.speedRegions,
|
||||
);
|
||||
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
|
||||
const readEndSec = Math.max(videoInfo.duration, videoInfo.streamDuration ?? 0) + 0.5;
|
||||
|
||||
console.log("[VideoExporter] Original duration:", videoInfo.duration, "s");
|
||||
console.log("[VideoExporter] Effective duration:", effectiveDuration, "s");
|
||||
console.log("[VideoExporter] Total frames to export:", totalFrames);
|
||||
console.log("[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)");
|
||||
|
||||
const frameDuration = 1_000_000 / this.config.frameRate;
|
||||
let frameIndex = 0;
|
||||
@@ -234,25 +235,29 @@ export class VideoExporter {
|
||||
|
||||
const canvas = renderer.getCanvas();
|
||||
|
||||
// Read raw pixels from the canvas instead of passing
|
||||
// the canvas directly to VideoFrame. On some Linux
|
||||
// systems the GPU shared-image path (EGL/Ozone) fails
|
||||
// silently, producing empty frames.
|
||||
const canvasCtx = canvas.getContext("2d")!;
|
||||
const imageData = canvasCtx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const exportFrame = new VideoFrame(imageData.data.buffer, {
|
||||
format: "RGBA",
|
||||
codedWidth: canvas.width,
|
||||
codedHeight: canvas.height,
|
||||
timestamp,
|
||||
duration: frameDuration,
|
||||
colorSpace: {
|
||||
primaries: "bt709",
|
||||
transfer: "iec61966-2-1",
|
||||
matrix: "rgb",
|
||||
fullRange: true,
|
||||
},
|
||||
});
|
||||
let exportFrame: VideoFrame;
|
||||
|
||||
// On some Linux systems the GPU shared-image path (EGL/Ozone) fails
|
||||
// silently, producing empty frames, so we force a CPU readback instead.
|
||||
if (platform === "linux") {
|
||||
const canvasCtx = canvas.getContext("2d")!;
|
||||
const imageData = canvasCtx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
exportFrame = new VideoFrame(imageData.data.buffer, {
|
||||
format: "RGBA",
|
||||
codedWidth: canvas.width,
|
||||
codedHeight: canvas.height,
|
||||
timestamp,
|
||||
duration: frameDuration,
|
||||
colorSpace: {
|
||||
primaries: "bt709",
|
||||
transfer: "iec61966-2-1",
|
||||
matrix: "rgb",
|
||||
fullRange: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
exportFrame = new VideoFrame(canvas, { timestamp, duration: frameDuration });
|
||||
}
|
||||
|
||||
while (
|
||||
this.encoder &&
|
||||
@@ -343,7 +348,7 @@ export class VideoExporter {
|
||||
this.config.videoUrl,
|
||||
this.config.trimRegions,
|
||||
this.config.speedRegions,
|
||||
readEndSec,
|
||||
videoInfo.duration,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -419,7 +424,7 @@ export class VideoExporter {
|
||||
})();
|
||||
|
||||
this.muxingPromises.push(muxingPromise);
|
||||
this.encodeQueue--;
|
||||
this.encodeQueue = Math.max(0, this.encodeQueue - 1);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error("[VideoExporter] Encoder error:", error);
|
||||
|
||||
@@ -17,9 +17,7 @@ export async function requestCameraAccess(): Promise<CameraAccessResult> {
|
||||
if (window.electronAPI?.requestCameraAccess) {
|
||||
try {
|
||||
const electronResult = await window.electronAPI.requestCameraAccess();
|
||||
if (!electronResult.success || !electronResult.granted) {
|
||||
return electronResult;
|
||||
}
|
||||
return electronResult;
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -3,6 +3,7 @@ export const SHORTCUT_ACTIONS = [
|
||||
"addTrim",
|
||||
"addSpeed",
|
||||
"addAnnotation",
|
||||
"addBlur",
|
||||
"addKeyframe",
|
||||
"deleteSelected",
|
||||
"playPause",
|
||||
@@ -108,6 +109,7 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = {
|
||||
addTrim: { key: "t" },
|
||||
addSpeed: { key: "s" },
|
||||
addAnnotation: { key: "a" },
|
||||
addBlur: { key: "b" },
|
||||
addKeyframe: { key: "f" },
|
||||
deleteSelected: { key: "d", ctrl: true },
|
||||
playPause: { key: " " },
|
||||
@@ -118,6 +120,7 @@ export const SHORTCUT_LABELS: Record<ShortcutAction, string> = {
|
||||
addTrim: "Add Trim",
|
||||
addSpeed: "Add Speed",
|
||||
addAnnotation: "Add Annotation",
|
||||
addBlur: "Add Blur",
|
||||
addKeyframe: "Add Keyframe",
|
||||
deleteSelected: "Delete Selected",
|
||||
playPause: "Play / Pause",
|
||||
@@ -125,9 +128,10 @@ export const SHORTCUT_LABELS: Record<ShortcutAction, string> = {
|
||||
|
||||
export function matchesShortcut(
|
||||
e: KeyboardEvent,
|
||||
binding: ShortcutBinding,
|
||||
binding: ShortcutBinding | undefined,
|
||||
isMacPlatform: boolean,
|
||||
): boolean {
|
||||
if (!binding) return false;
|
||||
if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false;
|
||||
|
||||
const primaryMod = isMacPlatform ? e.metaKey : e.ctrlKey;
|
||||
|
||||
@@ -4,6 +4,17 @@ import App from "./App.tsx";
|
||||
import { I18nProvider } from "./contexts/I18nContext";
|
||||
import "./index.css";
|
||||
|
||||
const windowType = new URLSearchParams(window.location.search).get("windowType") || "";
|
||||
if (
|
||||
windowType === "hud-overlay" ||
|
||||
windowType === "source-selector" ||
|
||||
windowType === "countdown-overlay"
|
||||
) {
|
||||
document.body.style.background = "transparent";
|
||||
document.documentElement.style.background = "transparent";
|
||||
document.getElementById("root")?.style.setProperty("background", "transparent");
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<I18nProvider>
|
||||
|
||||
@@ -3,7 +3,7 @@ let cachedPlatform: string | null = null;
|
||||
/**
|
||||
* Gets the current platform from Electron
|
||||
*/
|
||||
const getPlatform = async (): Promise<string> => {
|
||||
export const getPlatform = async (): Promise<string> => {
|
||||
if (cachedPlatform) return cachedPlatform;
|
||||
|
||||
try {
|
||||
@@ -14,9 +14,14 @@ const getPlatform = async (): Promise<string> => {
|
||||
console.warn("Failed to get platform from Electron, falling back to navigator:", error);
|
||||
// Fallback for development/testing
|
||||
let fallbackPlatform = "win32";
|
||||
if (typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform)) {
|
||||
fallbackPlatform = "darwin";
|
||||
if (typeof navigator !== "undefined") {
|
||||
if (/Mac|iPhone|iPad|iPod/.test(navigator.platform)) {
|
||||
fallbackPlatform = "darwin";
|
||||
} else if (/Linux/.test(navigator.platform)) {
|
||||
fallbackPlatform = "linux";
|
||||
}
|
||||
}
|
||||
|
||||
cachedPlatform = fallbackPlatform;
|
||||
return fallbackPlatform;
|
||||
}
|
||||
|
||||
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>;
|
||||
|
||||
Reference in New Issue
Block a user