Merge branch 'main' into feature/pause-button

This commit is contained in:
Manish
2026-04-05 22:03:35 +05:30
committed by GitHub
39 changed files with 924 additions and 110 deletions
+3 -2
View File
@@ -94,6 +94,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: linux-installer
path: release/**/*.AppImage
path: |
release/**/*.AppImage
release/**/*.zsync
retention-days: 30
+4
View File
@@ -8,6 +8,10 @@
<a href="https://deepwiki.com/siddharthvaddem/openscreen">
<img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" />
</a>
&nbsp;
<a href="https://discord.gg/yAQQhRaEeg">
<img src="https://img.shields.io/discord/pHAUbcqNd?logo=discord&label=Discord&color=5865F2" alt="Join Discord" />
</a>
</p>
# <p align="center">OpenScreen</p>
+9 -8
View File
@@ -20,14 +20,15 @@
"!CONTRIBUTING.md",
"!LICENSE"
],
"extraResources": [
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
}
],
"mac": {
"extraResources": [
{
"from": "public/wallpapers",
"to": "assets/wallpapers"
}
],
"publish": [{"provider": "github"}],
"mac": {
"hardenedRuntime": false,
"target": [
{
+15
View File
@@ -5,6 +5,7 @@ import { FaRegStopCircle } from "react-icons/fa";
import { FaFolderOpen } from "react-icons/fa6";
import { FiMinus, FiX } from "react-icons/fi";
import {
MdCancel,
MdMic,
MdMicOff,
MdMonitor,
@@ -45,6 +46,7 @@ const ICON_CONFIG = {
resume: { icon: BsPlayCircle, size: ICON_SIZE },
stop: { icon: FaRegStopCircle, size: ICON_SIZE },
restart: { icon: MdRestartAlt, size: ICON_SIZE },
cancel: { icon: MdCancel, size: ICON_SIZE },
record: { icon: BsRecordCircle, size: ICON_SIZE },
videoFile: { icon: MdVideoFile, size: ICON_SIZE },
folder: { icon: FaFolderOpen, size: ICON_SIZE },
@@ -84,6 +86,7 @@ export function LaunchWindow() {
toggleRecording,
togglePaused,
restartRecording,
cancelRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
@@ -478,6 +481,18 @@ export function LaunchWindow() {
</Tooltip>
)}
{/* Cancel recording */}
{recording && (
<Tooltip content={t("tooltips.cancelRecording")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={cancelRecording}
>
{getIcon("cancel", "text-white/60")}
</button>
</Tooltip>
)}
{/* Open video file */}
<Tooltip content={t("tooltips.openVideoFile")}>
<button
@@ -37,8 +37,10 @@ export function KeyboardShortcutsHelp() {
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
{FIXED_SHORTCUTS.map((fixed) => (
<div key={fixed.label} className="flex items-center justify-between">
<span className="text-slate-400">{fixed.label}</span>
<div key={fixed.i18nKey} className="flex items-center justify-between">
<span className="text-slate-400">
{t(`fixedActions.${fixed.i18nKey}`, { defaultValue: fixed.label })}
</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{isMac
? fixed.display
@@ -51,7 +51,9 @@ import type {
FigureData,
PlaybackSpeed,
WebcamLayoutPreset,
WebcamMaskShape,
ZoomDepth,
ZoomFocusMode,
} from "./types";
import { SPEED_OPTIONS } from "./types";
@@ -92,6 +94,9 @@ interface SettingsPanelProps {
onWallpaperChange: (path: string) => void;
selectedZoomDepth?: ZoomDepth | null;
onZoomDepthChange?: (depth: ZoomDepth) => void;
selectedZoomFocusMode?: ZoomFocusMode | null;
onZoomFocusModeChange?: (mode: ZoomFocusMode) => void;
hasCursorTelemetry?: boolean;
selectedZoomId?: string | null;
onZoomDelete?: (id: string) => void;
selectedTrimId?: string | null;
@@ -143,6 +148,8 @@ interface SettingsPanelProps {
hasWebcam?: boolean;
webcamLayoutPreset?: WebcamLayoutPreset;
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
webcamMaskShape?: import("./types").WebcamMaskShape;
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
}
export default SettingsPanel;
@@ -161,6 +168,9 @@ export function SettingsPanel({
onWallpaperChange,
selectedZoomDepth,
onZoomDepthChange,
selectedZoomFocusMode,
onZoomFocusModeChange,
hasCursorTelemetry = false,
selectedZoomId,
onZoomDelete,
selectedTrimId,
@@ -211,6 +221,8 @@ export function SettingsPanel({
hasWebcam = false,
webcamLayoutPreset = "picture-in-picture",
onWebcamLayoutPresetChange,
webcamMaskShape = "rectangle",
onWebcamMaskShapeChange,
}: SettingsPanelProps) {
const t = useScopedT("settings");
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
@@ -500,6 +512,41 @@ export function SettingsPanel({
{!zoomEnabled && (
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("zoom.selectRegion")}</p>
)}
{zoomEnabled && hasCursorTelemetry && (
<div className="mt-3">
<span className="text-sm font-medium text-slate-200 mb-2 block">
{t("zoom.focusMode.title")}
</span>
<div className="grid grid-cols-2 gap-1.5">
{(["manual", "auto"] as const).map((mode) => {
const isActive = selectedZoomFocusMode === mode;
return (
<Button
key={mode}
type="button"
onClick={() => onZoomFocusModeChange?.(mode)}
className={cn(
"h-auto w-full rounded-lg border px-2 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-xs font-semibold capitalize">
{t(`zoom.focusMode.${mode}`)}
</span>
</Button>
);
})}
</div>
{selectedZoomFocusMode === "auto" && (
<p className="text-[10px] text-slate-500 mt-1.5">
{t("zoom.focusMode.autoDescription")}
</p>
)}
</div>
)}
{zoomEnabled && (
<Button
onClick={handleDeleteClick}
@@ -623,6 +670,87 @@ export function SettingsPanel({
</SelectContent>
</Select>
</div>
{webcamLayoutPreset === "picture-in-picture" && (
<div className="mt-2 p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[10px] font-medium text-slate-300 mb-1.5">
{t("layout.webcamShape")}
</div>
<div className="grid grid-cols-4 gap-1.5">
{(
[
{ value: "rectangle", label: "Rect" },
{ value: "circle", label: "Circle" },
{ value: "square", label: "Square" },
{ value: "rounded", label: "Rounded" },
] as Array<{ value: WebcamMaskShape; label: string }>
).map((shape) => (
<button
key={shape.value}
type="button"
onClick={() => onWebcamMaskShapeChange?.(shape.value)}
className={cn(
"h-10 rounded-lg border flex flex-col items-center justify-center gap-0.5 transition-all",
webcamMaskShape === shape.value
? "bg-[#34B27B] border-[#34B27B] text-white"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 text-slate-400",
)}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{shape.value === "rectangle" && (
<rect
x="1"
y="3"
width="14"
height="10"
rx="2"
stroke="currentColor"
strokeWidth="1.5"
/>
)}
{shape.value === "circle" && (
<circle
cx="8"
cy="8"
r="6.5"
stroke="currentColor"
strokeWidth="1.5"
/>
)}
{shape.value === "square" && (
<rect
x="2"
y="2"
width="12"
height="12"
rx="1"
stroke="currentColor"
strokeWidth="1.5"
/>
)}
{shape.value === "rounded" && (
<rect
x="1"
y="3"
width="14"
height="10"
rx="5"
stroke="currentColor"
strokeWidth="1.5"
/>
)}
</svg>
<span className="text-[8px] leading-none">{shape.label}</span>
</button>
))}
</div>
</div>
)}
</AccordionContent>
</AccordionItem>
)}
@@ -197,12 +197,14 @@ export function ShortcutsConfigDialog() {
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
{t("fixed")}
</p>
{FIXED_SHORTCUTS.map(({ label, display }) => (
{FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => (
<div
key={label}
key={i18nKey}
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
>
<span className="text-sm text-slate-400">{label}</span>
<span className="text-sm text-slate-400">
{t(`fixedActions.${i18nKey}`, { defaultValue: label })}
</span>
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
{display}
</kbd>
@@ -20,6 +20,7 @@ import {
type GifSizePreset,
VideoExporter,
} from "@/lib/exporter";
import { computeFrameStepTime } from "@/lib/frameStep";
import type { ProjectMedia } from "@/lib/recordingSession";
import { matchesShortcut } from "@/lib/shortcuts";
import {
@@ -56,6 +57,7 @@ import {
type TrimRegion,
type ZoomDepth,
type ZoomFocus,
type ZoomFocusMode,
type ZoomRegion,
} from "./types";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
@@ -84,6 +86,7 @@ export default function VideoEditor() {
padding,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
} = editorState;
@@ -98,6 +101,10 @@ export default function VideoEditor() {
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const currentTimeRef = useRef(currentTime);
currentTimeRef.current = currentTime;
const durationRef = useRef(duration);
durationRef.current = duration;
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
@@ -195,6 +202,7 @@ export default function VideoEditor() {
annotationRegions: normalizedEditor.annotationRegions,
aspectRatio: normalizedEditor.aspectRatio,
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
webcamMaskShape: normalizedEditor.webcamMaskShape,
webcamPosition: normalizedEditor.webcamPosition,
});
setExportQuality(normalizedEditor.exportQuality);
@@ -264,6 +272,7 @@ export default function VideoEditor() {
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
@@ -287,6 +296,7 @@ export default function VideoEditor() {
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
@@ -380,6 +390,7 @@ export default function VideoEditor() {
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
@@ -434,6 +445,7 @@ export default function VideoEditor() {
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
@@ -688,6 +700,18 @@ export default function VideoEditor() {
[selectedZoomId, pushState],
);
const handleZoomFocusModeChange = useCallback(
(focusMode: ZoomFocusMode) => {
if (!selectedZoomId) return;
pushState((prev) => ({
zoomRegions: prev.zoomRegions.map((region) =>
region.id === selectedZoomId ? { ...region, focusMode } : region,
),
}));
},
[selectedZoomId, pushState],
);
const handleZoomDelete = useCallback(
(id: string) => {
pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) }));
@@ -926,6 +950,40 @@ export default function VideoEditor() {
return;
}
// Frame-step navigation (arrow keys, no modifiers)
if (
(e.key === "ArrowLeft" || e.key === "ArrowRight") &&
!e.ctrlKey &&
!e.metaKey &&
!e.shiftKey &&
!e.altKey
) {
const target = e.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement &&
(target.isContentEditable ||
target.closest('[role="separator"], [role="slider"], [role="spinbutton"]')))
) {
return;
}
e.preventDefault();
const video = videoPlaybackRef.current?.video;
if (!video) {
return;
}
const direction = e.key === "ArrowLeft" ? "backward" : "forward";
const newTime = computeFrameStepTime(
video.currentTime,
Number.isFinite(video.duration) ? video.duration : durationRef.current,
direction,
);
video.currentTime = newTime;
return;
}
const isInput =
e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
@@ -1090,9 +1148,11 @@ export default function VideoEditor() {
cropRegion,
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
previewWidth,
previewHeight,
cursorTelemetry,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1221,9 +1281,11 @@ export default function VideoEditor() {
cropRegion,
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
previewWidth,
previewHeight,
cursorTelemetry,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1289,9 +1351,11 @@ export default function VideoEditor() {
isPlaying,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
handleExportSaved,
cursorTelemetry,
],
);
@@ -1473,6 +1537,7 @@ export default function VideoEditor() {
videoPath={videoPath || ""}
webcamVideoPath={webcamVideoPath || undefined}
webcamLayoutPreset={webcamLayoutPreset}
webcamMaskShape={webcamMaskShape}
webcamPosition={webcamPosition}
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
onWebcamPositionDragEnd={commitState}
@@ -1502,6 +1567,7 @@ export default function VideoEditor() {
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
cursorTelemetry={cursorTelemetry}
/>
</div>
</div>
@@ -1584,6 +1650,13 @@ export default function VideoEditor() {
selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null
}
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
selectedZoomFocusMode={
selectedZoomId
? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual")
: null
}
onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode)}
hasCursorTelemetry={cursorTelemetry.length > 0}
selectedZoomId={selectedZoomId}
onZoomDelete={handleZoomDelete}
selectedTrimId={selectedTrimId}
@@ -1613,6 +1686,8 @@ export default function VideoEditor() {
webcamPosition: preset === "vertical-stack" ? null : webcamPosition,
})
}
webcamMaskShape={webcamMaskShape}
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
videoElement={videoPlaybackRef.current?.video || null}
exportQuality={exportQuality}
onExportQualityChange={setExportQuality}
+108 -26
View File
@@ -25,6 +25,7 @@ import {
type StyledRenderRect,
type WebcamLayoutPreset,
} from "@/lib/compositeLayout";
import { getCssClipPath } from "@/lib/webcamMaskShapes";
import {
type AspectRatio,
formatAspectRatioForCSS,
@@ -41,10 +42,14 @@ import {
type ZoomRegion,
} from "./types";
import {
AUTO_FOLLOW_RAMP_DISTANCE,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
DEFAULT_FOCUS,
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "./videoPlayback/constants";
import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
@@ -63,6 +68,7 @@ interface VideoPlaybackProps {
videoPath: string;
webcamVideoPath?: string;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape?: import("./types").WebcamMaskShape;
webcamPosition?: { cx: number; cy: number } | null;
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
onWebcamPositionDragEnd?: () => void;
@@ -93,6 +99,7 @@ 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;
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
}
export interface VideoPlaybackRef {
@@ -111,6 +118,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoPath,
webcamVideoPath,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
onWebcamPositionChange,
onWebcamPositionDragEnd,
@@ -141,6 +149,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
onSelectAnnotation,
onAnnotationPositionChange,
onAnnotationSizeChange,
cursorTelemetry = [],
},
ref,
) => {
@@ -160,6 +169,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const [webcamDimensions, setWebcamDimensions] = useState<Size | null>(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
const cursorTelemetryRef = useRef<import("./types").CursorTelemetryPoint[]>([]);
const selectedZoomIdRef = useRef<string | null>(null);
const animationStateRef = useRef({
scale: 1,
@@ -194,6 +204,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const onTimeUpdateRef = useRef(onTimeUpdate);
const onPlayStateChangeRef = useRef(onPlayStateChange);
const videoReadyRafRef = useRef<number | null>(null);
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
const prevTargetProgressRef = useRef(0);
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
@@ -272,6 +284,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamDimensions,
webcamLayoutPreset,
webcamPosition,
webcamMaskShape,
});
if (result) {
@@ -302,6 +315,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamDimensions,
webcamLayoutPreset,
webcamPosition,
webcamMaskShape,
]);
useEffect(() => {
@@ -379,6 +393,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
if (!regionId) return;
const region = zoomRegionsRef.current.find((r) => r.id === regionId);
if (!region) return;
if (region.focusMode === "auto") return;
onSelectZoom(region.id);
event.preventDefault();
isDraggingFocusRef.current = true;
@@ -462,6 +477,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
zoomRegionsRef.current = zoomRegions;
}, [zoomRegions]);
useEffect(() => {
cursorTelemetryRef.current = cursorTelemetry;
}, [cursorTelemetry]);
useEffect(() => {
selectedZoomIdRef.current = selectedZoomId;
}, [selectedZoomId]);
@@ -830,10 +849,16 @@ 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 },
{ connectZooms: true, cursorTelemetry: cursorTelemetryRef.current, viewportRatio },
);
const defaultFocus = DEFAULT_FOCUS;
@@ -854,6 +879,47 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
targetFocus = regionFocus;
targetProgress = strength;
// Apply adaptive smoothing for auto-follow mode
if (region.focusMode === "auto" && !transition) {
const raw = targetFocus;
const isZoomingIn =
targetProgress < 0.999 && targetProgress >= prevTargetProgressRef.current;
if (targetProgress >= 0.999) {
// Full zoom: adaptive smoothing — moves faster when far, decelerates when close
const prev = smoothedAutoFocusRef.current ?? raw;
const factor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const smoothed = smoothCursorFocus(raw, prev, factor);
smoothedAutoFocusRef.current = smoothed;
targetFocus = smoothed;
} else if (isZoomingIn) {
// Zoom-in: track cursor directly so zoom always aims at current cursor
// position; keep ref in sync to avoid snap when full-zoom begins
smoothedAutoFocusRef.current = raw;
} else {
// Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start
const prev = smoothedAutoFocusRef.current ?? raw;
const factor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const smoothed = smoothCursorFocus(raw, prev, factor);
smoothedAutoFocusRef.current = smoothed;
targetFocus = smoothed;
}
} else if (region.focusMode !== "auto") {
smoothedAutoFocusRef.current = null;
}
prevTargetProgressRef.current = targetProgress;
// Handle connected zoom transitions (pan between adjacent zoom regions)
if (transition) {
const startTransform = computeZoomTransform({
@@ -1154,31 +1220,47 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
: "none",
}}
/>
{webcamVideoPath && (
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className={`absolute object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
style={{
left: webcamLayout?.x ?? 0,
top: webcamLayout?.y ?? 0,
width: webcamLayout?.width ?? 0,
height: webcamLayout?.height ?? 0,
borderRadius: webcamLayout?.borderRadius ?? 0,
boxShadow: webcamCssBoxShadow,
zIndex: 20,
opacity: webcamLayout ? 1 : 0,
backgroundColor: "#000",
}}
onPointerDown={handleWebcamPointerDown}
onPointerMove={handleWebcamPointerMove}
onPointerUp={handleWebcamPointerUp}
onPointerLeave={handleWebcamPointerUp}
muted
preload="metadata"
playsInline
/>
)}
{webcamVideoPath &&
(() => {
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
const useClipPath = !!clipPath;
return (
<div
className="absolute"
style={{
left: webcamLayout?.x ?? 0,
top: webcamLayout?.y ?? 0,
width: webcamLayout?.width ?? 0,
height: webcamLayout?.height ?? 0,
zIndex: 20,
opacity: webcamLayout ? 1 : 0,
filter:
useClipPath && webcamCssBoxShadow !== "none"
? `drop-shadow(${webcamCssBoxShadow})`
: undefined,
}}
>
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
style={{
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
clipPath: clipPath ?? undefined,
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
backgroundColor: "#000",
}}
onPointerDown={handleWebcamPointerDown}
onPointerMove={handleWebcamPointerMove}
onPointerUp={handleWebcamPointerUp}
onPointerLeave={handleWebcamPointerUp}
muted
preload="metadata"
playsInline
/>
</div>
);
})()}
{/* Only render overlay after PIXI and video are fully initialized */}
{pixiReady && videoReady && (
<div
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
createProjectData,
normalizeProjectEditor,
PROJECT_VERSION,
resolveProjectMedia,
validateProjectData,
@@ -40,6 +41,7 @@ describe("projectPersistence media compatibility", () => {
annotationRegions: [],
aspectRatio: "16:9",
webcamLayoutPreset: "picture-in-picture",
webcamMaskShape: "circle",
exportQuality: "good",
exportFormat: "mp4",
gifFrameRate: 15,
@@ -55,4 +57,11 @@ describe("projectPersistence media compatibility", () => {
});
expect(validateProjectData(project)).toBe(true);
});
it("normalizes webcam mask shape values safely", () => {
expect(normalizeProjectEditor({ webcamMaskShape: "rounded" }).webcamMaskShape).toBe("rounded");
expect(
normalizeProjectEditor({ webcamMaskShape: "not-a-real-shape" as never }).webcamMaskShape,
).toBe("rectangle");
});
});
@@ -12,11 +12,13 @@ import {
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_MASK_SHAPE,
DEFAULT_WEBCAM_POSITION,
DEFAULT_ZOOM_DEPTH,
type SpeedRegion,
type TrimRegion,
type WebcamLayoutPreset,
type WebcamMaskShape,
type WebcamPosition,
type ZoomRegion,
} from "./types";
@@ -44,6 +46,7 @@ export interface ProjectEditorState {
annotationRegions: AnnotationRegion[];
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
webcamPosition: WebcamPosition | null;
exportQuality: ExportQuality;
exportFormat: ExportFormat;
@@ -189,6 +192,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1),
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
},
focusMode: region.focusMode === "auto" ? "auto" : "manual",
};
})
: [];
@@ -352,6 +356,13 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
editor.webcamLayoutPreset === "picture-in-picture"
? editor.webcamLayoutPreset
: DEFAULT_WEBCAM_LAYOUT_PRESET,
webcamMaskShape:
editor.webcamMaskShape === "rectangle" ||
editor.webcamMaskShape === "circle" ||
editor.webcamMaskShape === "square" ||
editor.webcamMaskShape === "rounded"
? editor.webcamMaskShape
: DEFAULT_WEBCAM_MASK_SHAPE,
webcamPosition:
editor.webcamPosition &&
typeof editor.webcamPosition === "object" &&
+6
View File
@@ -1,10 +1,15 @@
import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
export type ZoomFocusMode = "manual" | "auto";
export type { WebcamLayoutPreset };
export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
export type WebcamMaskShape = "rectangle" | "circle" | "square" | "rounded";
export const DEFAULT_WEBCAM_MASK_SHAPE: WebcamMaskShape = "rectangle";
export interface WebcamPosition {
cx: number; // normalized horizontal center (0-1)
cy: number; // normalized vertical center (0-1)
@@ -23,6 +28,7 @@ export interface ZoomRegion {
endMs: number;
depth: ZoomDepth;
focus: ZoomFocus;
focusMode?: ZoomFocusMode;
}
export interface CursorTelemetryPoint {
@@ -8,3 +8,6 @@ export const VIEWPORT_SCALE = 0.8;
export const SMOOTHING_FACTOR = 0.12;
export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25;
export const ZOOM_SCALE_DEADZONE = 0.002;
export const AUTO_FOLLOW_SMOOTHING_FACTOR = 0.1;
export const AUTO_FOLLOW_SMOOTHING_FACTOR_MAX = 0.25;
export const AUTO_FOLLOW_RAMP_DISTANCE = 0.15;
@@ -0,0 +1,73 @@
import type { CursorTelemetryPoint, ZoomFocus } from "../types";
/**
* Binary-search the sorted telemetry array and linearly interpolate
* the cursor position at the given playback time.
*/
export function interpolateCursorAt(
telemetry: CursorTelemetryPoint[],
timeMs: number,
): ZoomFocus | null {
if (telemetry.length === 0) return null;
if (timeMs <= telemetry[0].timeMs) {
return { cx: telemetry[0].cx, cy: telemetry[0].cy };
}
const last = telemetry[telemetry.length - 1];
if (timeMs >= last.timeMs) {
return { cx: last.cx, cy: last.cy };
}
let lo = 0;
let hi = telemetry.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >>> 1;
if (telemetry[mid].timeMs <= timeMs) {
lo = mid;
} else {
hi = mid;
}
}
const before = telemetry[lo];
const after = telemetry[hi];
const span = after.timeMs - before.timeMs;
const t = span > 0 ? (timeMs - before.timeMs) / span : 0;
return {
cx: before.cx + (after.cx - before.cx) * t,
cy: before.cy + (after.cy - before.cy) * t,
};
}
/**
* Exponential smoothing to reduce jitter from high-frequency cursor data.
* Lower factor = smoother / more lag, higher = more responsive.
*/
export function smoothCursorFocus(raw: ZoomFocus, prev: ZoomFocus, factor: number): ZoomFocus {
return {
cx: prev.cx + (raw.cx - prev.cx) * factor,
cy: prev.cy + (raw.cy - prev.cy) * factor,
};
}
/**
* Compute an adaptive smoothing factor that scales with distance:
* far from target faster (maxFactor), close slower (minFactor).
* This replaces the hard deadzone with a natural deceleration curve.
*/
export function adaptiveSmoothFactor(
raw: ZoomFocus,
prev: ZoomFocus,
minFactor: number,
maxFactor: number,
rampDistance: number,
): number {
const dx = raw.cx - prev.cx;
const dy = raw.cy - prev.cy;
const distance = Math.sqrt(dx * dx + dy * dy);
const t = Math.min(1, distance / rampDistance);
return minFactor + (maxFactor - minFactor) * t;
}
@@ -39,9 +39,16 @@ function getFocusBounds(depth: ZoomDepth) {
return getFocusBoundsForScale(zoomScale);
}
function getFocusBoundsForScale(zoomScale: number) {
const marginX = 1 / (2 * zoomScale);
const marginY = 1 / (2 * zoomScale);
interface ViewportRatio {
widthRatio: number;
heightRatio: number;
}
function getFocusBoundsForScale(zoomScale: number, viewportRatio?: ViewportRatio) {
const wr = viewportRatio?.widthRatio ?? 1;
const hr = viewportRatio?.heightRatio ?? 1;
const marginX = Math.min(0.5, wr / (2 * zoomScale));
const marginY = Math.min(0.5, hr / (2 * zoomScale));
return {
minX: marginX,
@@ -65,12 +72,16 @@ export function clampFocusToStage(
};
}
export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
export function clampFocusToScale(
focus: ZoomFocus,
zoomScale: number,
viewportRatio?: ViewportRatio,
): ZoomFocus {
const baseFocus = {
cx: clamp(focus.cx, 0, 1),
cy: clamp(focus.cy, 0, 1),
};
const bounds = getFocusBoundsForScale(zoomScale);
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
return {
cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
@@ -78,12 +89,16 @@ export function clampFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocu
};
}
export function softenFocusToScale(focus: ZoomFocus, zoomScale: number): ZoomFocus {
export function softenFocusToScale(
focus: ZoomFocus,
zoomScale: number,
viewportRatio?: ViewportRatio,
): ZoomFocus {
const baseFocus = {
cx: clamp(focus.cx, 0, 1),
cy: clamp(focus.cy, 0, 1),
};
const bounds = getFocusBoundsForScale(zoomScale);
const bounds = getFocusBoundsForScale(zoomScale, viewportRatio);
const horizontalRange = bounds.maxX - bounds.minX;
const verticalRange = bounds.maxY - bounds.minY;
const horizontalSoftness = Math.min(0.12, horizontalRange * 0.35);
@@ -6,7 +6,7 @@ import {
type StyledRenderRect,
type WebcamLayoutPreset,
} from "@/lib/compositeLayout";
import type { CropRegion } from "../types";
import type { CropRegion, WebcamMaskShape } from "../types";
interface LayoutParams {
container: HTMLDivElement;
@@ -21,6 +21,7 @@ interface LayoutParams {
webcamDimensions?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamPosition?: { cx: number; cy: number } | null;
webcamMaskShape?: WebcamMaskShape;
}
interface LayoutResult {
@@ -47,6 +48,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
webcamDimensions,
webcamLayoutPreset,
webcamPosition,
webcamMaskShape,
} = params;
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
@@ -94,6 +96,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
webcamSize: webcamDimensions,
layoutPreset: webcamLayoutPreset,
webcamPosition,
webcamMaskShape,
});
if (!compositeLayout) {
@@ -14,7 +14,7 @@ interface OverlayUpdateParams {
export function updateOverlayIndicator(params: OverlayUpdateParams) {
const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params;
if (!region) {
if (!region || region.focusMode === "auto") {
indicatorEl.style.display = "none";
overlayEl.style.pointerEvents = "none";
return;
@@ -1,6 +1,7 @@
import type { ZoomFocus, ZoomRegion } from "../types";
import type { CursorTelemetryPoint, ZoomFocus, ZoomRegion } from "../types";
import { ZOOM_DEPTH_SCALES } from "../types";
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
import { interpolateCursorAt } from "./cursorFollowUtils";
import { clampFocusToScale } from "./focusUtils";
import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
@@ -10,6 +11,8 @@ const ZOOM_IN_OVERLAP_MS = 500;
type DominantRegionOptions = {
connectZooms?: boolean;
cursorTelemetry?: CursorTelemetryPoint[];
viewportRatio?: ViewportRatio;
};
type ConnectedRegionPair = {
@@ -64,8 +67,33 @@ function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomF
};
}
function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus {
return clampFocusToScale(region.focus, zoomScale);
interface ViewportRatio {
widthRatio: number;
heightRatio: number;
}
function getResolvedFocus(
region: ZoomRegion,
zoomScale: number,
timeMs?: number,
cursorTelemetry?: CursorTelemetryPoint[],
viewportRatio?: ViewportRatio,
): ZoomFocus {
let focus = region.focus;
if (
region.focusMode === "auto" &&
cursorTelemetry &&
cursorTelemetry.length > 0 &&
timeMs !== undefined
) {
const cursorFocus = interpolateCursorAt(cursorTelemetry, timeMs);
if (cursorFocus) {
focus = cursorFocus;
}
}
return clampFocusToScale(focus, zoomScale, viewportRatio);
}
function getConnectedRegionPairs(regions: ZoomRegion[]) {
@@ -96,6 +124,8 @@ function getActiveRegion(
regions: ZoomRegion[],
timeMs: number,
connectedPairs: ConnectedRegionPair[],
cursorTelemetry?: CursorTelemetryPoint[],
viewportRatio?: ViewportRatio,
) {
const activeRegions = regions
.map((region) => {
@@ -130,21 +160,32 @@ function getActiveRegion(
return {
region: {
...activeRegion,
focus: getResolvedFocus(activeRegion, activeScale),
focus: getResolvedFocus(activeRegion, activeScale, timeMs, cursorTelemetry, viewportRatio),
},
strength: activeRegions[0].strength,
blendedScale: null,
};
}
function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionPair[]) {
function getConnectedRegionHold(
timeMs: number,
connectedPairs: ConnectedRegionPair[],
cursorTelemetry?: CursorTelemetryPoint[],
viewportRatio?: ViewportRatio,
) {
for (const pair of connectedPairs) {
if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
return {
region: {
...pair.nextRegion,
focus: getResolvedFocus(pair.nextRegion, nextScale),
focus: getResolvedFocus(
pair.nextRegion,
nextScale,
timeMs,
cursorTelemetry,
viewportRatio,
),
},
strength: 1,
blendedScale: null,
@@ -155,7 +196,12 @@ function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionP
return null;
}
function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], timeMs: number) {
function getConnectedRegionTransition(
connectedPairs: ConnectedRegionPair[],
timeMs: number,
cursorTelemetry?: CursorTelemetryPoint[],
viewportRatio?: ViewportRatio,
) {
for (const pair of connectedPairs) {
const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
@@ -169,8 +215,23 @@ function getConnectedRegionTransition(connectedPairs: ConnectedRegionPair[], tim
const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
const transitionScale = lerp(currentScale, nextScale, transitionProgress);
const currentFocus = getResolvedFocus(currentRegion, currentScale);
const nextFocus = getResolvedFocus(nextRegion, nextScale);
// Both regions share the same timeMs, so interpolate cursor once and reuse.
const sharedCursorFocus =
cursorTelemetry && cursorTelemetry.length > 0
? interpolateCursorAt(cursorTelemetry, timeMs)
: null;
const currentFocus = clampFocusToScale(
currentRegion.focusMode === "auto" && sharedCursorFocus
? sharedCursorFocus
: currentRegion.focus,
currentScale,
viewportRatio,
);
const nextFocus = clampFocusToScale(
nextRegion.focusMode === "auto" && sharedCursorFocus ? sharedCursorFocus : nextRegion.focus,
nextScale,
viewportRatio,
);
const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
return {
@@ -204,20 +265,22 @@ export function findDominantRegion(
transition: ConnectedPanTransition | null;
} {
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
const telemetry = options.cursorTelemetry;
const vr = options.viewportRatio;
if (options.connectZooms) {
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs);
const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs, telemetry, vr);
if (connectedTransition) {
return connectedTransition;
}
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs);
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs, telemetry, vr);
if (connectedHold) {
return { ...connectedHold, transition: null };
}
}
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs);
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, telemetry, vr);
return activeRegion
? { ...activeRegion, transition: null }
: { region: null, strength: 0, blendedScale: null, transition: null };
+4
View File
@@ -5,12 +5,14 @@ import type {
SpeedRegion,
TrimRegion,
WebcamLayoutPreset,
WebcamMaskShape,
WebcamPosition,
ZoomRegion,
} from "@/components/video-editor/types";
import {
DEFAULT_CROP_REGION,
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_MASK_SHAPE,
DEFAULT_WEBCAM_POSITION,
} from "@/components/video-editor/types";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
@@ -31,6 +33,7 @@ export interface EditorState {
padding: number;
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
webcamPosition: WebcamPosition | null;
}
@@ -48,6 +51,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
padding: 50,
aspectRatio: "16:9",
webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE,
webcamPosition: DEFAULT_WEBCAM_POSITION,
};
+2
View File
@@ -46,6 +46,7 @@ type UseScreenRecorderReturn = {
toggleRecording: () => void;
togglePaused: () => void;
restartRecording: () => void;
cancelRecording: () => void;
microphoneEnabled: boolean;
setMicrophoneEnabled: (enabled: boolean) => void;
microphoneDeviceId: string | undefined;
@@ -706,6 +707,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
toggleRecording,
togglePaused,
restartRecording,
cancelRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
+1
View File
@@ -3,6 +3,7 @@
"hideHUD": "Hide HUD",
"closeApp": "Close App",
"restartRecording": "Restart recording",
"cancelRecording": "Cancel recording",
"openVideoFile": "Open video file",
"openProject": "Open project"
},
+9 -2
View File
@@ -2,7 +2,13 @@
"zoom": {
"level": "Zoom Level",
"selectRegion": "Select a zoom region to adjust",
"deleteZoom": "Delete Zoom"
"deleteZoom": "Delete Zoom",
"focusMode": {
"title": "Focus Mode",
"manual": "Manual",
"auto": "Auto",
"autoDescription": "Camera follows the recorded cursor position"
}
},
"speed": {
"playbackSpeed": "Playback Speed",
@@ -17,7 +23,8 @@
"preset": "Preset",
"selectPreset": "Select preset",
"pictureInPicture": "Picture in Picture",
"verticalStack": "Vertical Stack"
"verticalStack": "Vertical Stack",
"webcamShape": "Camera Shape"
},
"effects": {
"title": "Video Effects",
+3 -1
View File
@@ -29,6 +29,8 @@
"cycleAnnotationsBackward": "Cycle Annotations Backward",
"deleteSelectedAlt": "Delete Selected (alt)",
"panTimeline": "Pan Timeline",
"zoomTimeline": "Zoom Timeline"
"zoomTimeline": "Zoom Timeline",
"frameBack": "Frame Back",
"frameForward": "Frame Forward"
}
}
+1
View File
@@ -3,6 +3,7 @@
"hideHUD": "Ocultar HUD",
"closeApp": "Cerrar aplicación",
"restartRecording": "Reiniciar grabación",
"cancelRecording": "Cancelar grabación",
"openVideoFile": "Abrir archivo de video",
"openProject": "Abrir proyecto"
},
+9 -2
View File
@@ -2,7 +2,13 @@
"zoom": {
"level": "Nivel de zoom",
"selectRegion": "Selecciona una región de zoom para ajustar",
"deleteZoom": "Eliminar zoom"
"deleteZoom": "Eliminar zoom",
"focusMode": {
"title": "Modo de enfoque",
"manual": "Manual",
"auto": "Auto",
"autoDescription": "La cámara sigue la posición del cursor grabado"
}
},
"speed": {
"playbackSpeed": "Velocidad de reproducción",
@@ -17,7 +23,8 @@
"preset": "Predefinido",
"selectPreset": "Seleccionar predefinido",
"pictureInPicture": "Imagen en imagen",
"verticalStack": "Apilado vertical"
"verticalStack": "Apilado vertical",
"webcamShape": "Forma de cámara"
},
"effects": {
"title": "Efectos de video",
+3 -1
View File
@@ -29,6 +29,8 @@
"cycleAnnotationsBackward": "Recorrer anotaciones hacia atrás",
"deleteSelectedAlt": "Eliminar seleccionado (alt)",
"panTimeline": "Desplazar línea de tiempo",
"zoomTimeline": "Zoom en línea de tiempo"
"zoomTimeline": "Zoom en línea de tiempo",
"frameBack": "Fotograma anterior",
"frameForward": "Fotograma siguiente"
}
}
+1
View File
@@ -3,6 +3,7 @@
"hideHUD": "隐藏控制面板",
"closeApp": "关闭应用",
"restartRecording": "重新开始录制",
"cancelRecording": "取消录制",
"openVideoFile": "打开视频文件",
"openProject": "打开项目"
},
+9 -2
View File
@@ -2,7 +2,13 @@
"zoom": {
"level": "缩放级别",
"selectRegion": "选择要调整的缩放区域",
"deleteZoom": "删除缩放"
"deleteZoom": "删除缩放",
"focusMode": {
"title": "对焦模式",
"manual": "手动",
"auto": "自动",
"autoDescription": "摄像头跟随录制时的光标位置"
}
},
"speed": {
"playbackSpeed": "播放速度",
@@ -17,7 +23,8 @@
"preset": "预设",
"selectPreset": "选择预设",
"pictureInPicture": "画中画",
"verticalStack": "垂直堆叠"
"verticalStack": "垂直堆叠",
"webcamShape": "摄像头形状"
},
"effects": {
"title": "视频效果",
+3 -1
View File
@@ -29,6 +29,8 @@
"cycleAnnotationsBackward": "向后切换标注",
"deleteSelectedAlt": "删除所选(替代)",
"panTimeline": "平移时间轴",
"zoomTimeline": "缩放时间轴"
"zoomTimeline": "缩放时间轴",
"frameBack": "上一帧",
"frameForward": "下一帧"
}
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { computeFrameStepTime, FRAME_DURATION_SEC } from "@/lib/frameStep";
describe("computeFrameStepTime", () => {
const duration = 10;
it("moves forward by one frame from the middle", () => {
const result = computeFrameStepTime(5, duration, "forward");
expect(result).toBeCloseTo(5 + FRAME_DURATION_SEC, 10);
});
it("moves backward by one frame from the middle", () => {
const result = computeFrameStepTime(5, duration, "backward");
expect(result).toBeCloseTo(5 - FRAME_DURATION_SEC, 10);
});
it("clamps to 0 when stepping backward at the beginning", () => {
const result = computeFrameStepTime(0, duration, "backward");
expect(result).toBe(0);
});
it("clamps to 0 when stepping backward near the beginning", () => {
const result = computeFrameStepTime(FRAME_DURATION_SEC / 2, duration, "backward");
expect(result).toBe(0);
});
it("clamps to duration when stepping forward at the end", () => {
const result = computeFrameStepTime(duration, duration, "forward");
expect(result).toBe(duration);
});
it("clamps to duration when stepping forward near the end", () => {
const result = computeFrameStepTime(duration - FRAME_DURATION_SEC / 2, duration, "forward");
expect(result).toBe(duration);
});
it("handles duration of 0 gracefully", () => {
expect(computeFrameStepTime(0, 0, "forward")).toBe(0);
expect(computeFrameStepTime(0, 0, "backward")).toBe(0);
});
});
+60 -14
View File
@@ -33,7 +33,7 @@ describe("computeCompositeLayout", () => {
).toBeLessThanOrEqual(1920);
});
it("centers the combined screen and webcam stack in vertical stack mode", () => {
it("uses cover-style full-width stacking in vertical stack mode", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
maxContentSize: { width: 1536, height: 864 },
@@ -44,21 +44,22 @@ describe("computeCompositeLayout", () => {
expect(layout).not.toBeNull();
expect(layout?.screenRect).toEqual({
x: 576,
y: 108,
width: 768,
height: 432,
x: 0,
y: 0,
width: 1920,
height: 0,
});
expect(layout?.webcamRect).toEqual({
x: 576,
y: 540,
width: 768,
height: 432,
x: 0,
y: 0,
width: 1920,
height: 1080,
borderRadius: 0,
});
expect(layout?.screenCover).toBe(true);
});
it("keeps the screen centered and omits the webcam when dimensions are unavailable", () => {
it("fills the canvas with the screen when vertical stack has no webcam", () => {
const layout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
maxContentSize: { width: 1536, height: 864 },
@@ -68,11 +69,56 @@ describe("computeCompositeLayout", () => {
expect(layout).not.toBeNull();
expect(layout?.screenRect).toEqual({
x: 192,
y: 108,
width: 1536,
height: 864,
x: 0,
y: 0,
width: 1920,
height: 1080,
});
expect(layout?.webcamRect).toBeNull();
expect(layout?.screenCover).toBe(true);
});
it("forces circular and square masks to use square dimensions", () => {
const circularLayout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
webcamMaskShape: "circle",
});
const squareLayout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
webcamMaskShape: "square",
});
expect(circularLayout?.webcamRect).not.toBeNull();
expect(squareLayout?.webcamRect).not.toBeNull();
expect(circularLayout?.webcamRect?.width).toBe(circularLayout?.webcamRect?.height);
expect(squareLayout?.webcamRect?.width).toBe(squareLayout?.webcamRect?.height);
expect(circularLayout?.webcamRect?.maskShape).toBe("circle");
expect(squareLayout?.webcamRect?.maskShape).toBe("square");
});
it("applies larger rounding for the rounded webcam mask", () => {
const roundedLayout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
webcamMaskShape: "rounded",
});
const rectangleLayout = computeCompositeLayout({
canvasSize: { width: 1920, height: 1080 },
screenSize: { width: 1920, height: 1080 },
webcamSize: { width: 1280, height: 720 },
webcamMaskShape: "rectangle",
});
expect(roundedLayout?.webcamRect).not.toBeNull();
expect(rectangleLayout?.webcamRect).not.toBeNull();
expect(roundedLayout?.webcamRect?.borderRadius).toBeGreaterThan(
rectangleLayout?.webcamRect?.borderRadius ?? 0,
);
expect(roundedLayout?.webcamRect?.maskShape).toBe("rounded");
});
});
+30 -9
View File
@@ -7,6 +7,7 @@ export interface RenderRect {
export interface StyledRenderRect extends RenderRect {
borderRadius: number;
maskShape?: import("@/components/video-editor/types").WebcamMaskShape;
}
export interface Size {
@@ -125,6 +126,7 @@ export function computeCompositeLayout(params: {
webcamSize?: Size | null;
layoutPreset?: WebcamLayoutPreset;
webcamPosition?: { cx: number; cy: number } | null;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
}): WebcamCompositeLayout | null {
const {
canvasSize,
@@ -133,6 +135,7 @@ export function computeCompositeLayout(params: {
webcamSize,
layoutPreset = "picture-in-picture",
webcamPosition,
webcamMaskShape = "rectangle",
} = params;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
const { width: screenWidth, height: screenHeight } = screenSize;
@@ -198,8 +201,15 @@ export function computeCompositeLayout(params: {
const maxWidth = Math.max(transform.minSize, canvasWidth * transform.maxStageFraction);
const maxHeight = Math.max(transform.minSize, canvasHeight * transform.maxStageFraction);
const scale = Math.min(maxWidth / webcamWidth, maxHeight / webcamHeight);
const width = Math.round(webcamWidth * scale);
const height = Math.round(webcamHeight * scale);
let width = Math.round(webcamWidth * scale);
let height = Math.round(webcamHeight * scale);
// Shape-specific dimension adjustments
if (webcamMaskShape === "circle" || webcamMaskShape === "square") {
const side = Math.min(width, height);
width = side;
height = side;
}
let webcamX: number;
let webcamY: number;
@@ -217,6 +227,22 @@ export function computeCompositeLayout(params: {
webcamY = Math.max(0, Math.round(canvasHeight - margin - height));
}
// Shape-specific border radius
let borderRadius: number;
if (webcamMaskShape === "rounded") {
borderRadius = Math.round(Math.min(width, height) * 0.3);
} else if (webcamMaskShape === "circle") {
borderRadius = Math.round(Math.min(width, height) / 2);
} else {
borderRadius = Math.min(
preset.borderRadius.max,
Math.max(
preset.borderRadius.min,
Math.round(Math.min(width, height) * preset.borderRadius.fraction),
),
);
}
return {
screenRect,
webcamRect: {
@@ -224,13 +250,8 @@ export function computeCompositeLayout(params: {
y: webcamY,
width,
height,
borderRadius: Math.min(
preset.borderRadius.max,
Math.max(
preset.borderRadius.min,
Math.round(Math.min(width, height) * preset.borderRadius.fraction),
),
),
borderRadius,
maskShape: webcamMaskShape,
},
};
}
+71 -4
View File
@@ -18,10 +18,17 @@ import type {
} from "@/components/video-editor/types";
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
import {
AUTO_FOLLOW_RAMP_DISTANCE,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
DEFAULT_FOCUS,
ZOOM_SCALE_DEADZONE,
ZOOM_TRANSLATION_DEADZONE_PX,
} from "@/components/video-editor/videoPlayback/constants";
import {
adaptiveSmoothFactor,
smoothCursorFocus,
} from "@/components/video-editor/videoPlayback/cursorFollowUtils";
import { clampFocusToStage as clampFocusToStageUtil } from "@/components/video-editor/videoPlayback/focusUtils";
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
import {
@@ -37,6 +44,7 @@ import {
type Size,
type StyledRenderRect,
} from "@/lib/compositeLayout";
import { drawCanvasClipPath } from "@/lib/webcamMaskShapes";
import { renderAnnotations } from "./annotationRenderer";
import {
getLinearGradientPoints,
@@ -61,11 +69,13 @@ interface FrameRenderConfig {
videoHeight: number;
webcamSize?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
speedRegions?: SpeedRegion[];
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
}
interface AnimationState {
@@ -107,6 +117,9 @@ export class FrameRenderer {
private layoutCache: LayoutCache | null = null;
private currentVideoTime = 0;
private motionBlurState: MotionBlurState = createMotionBlurState();
private smoothedAutoFocus: { cx: number; cy: number } | null = null;
private prevAnimationTimeMs: number | null = null;
private prevTargetProgress = 0;
constructor(config: FrameRenderConfig) {
this.config = config;
@@ -441,6 +454,7 @@ export class FrameRenderer {
webcamSize: webcamFrame ? this.config.webcamSize : null,
layoutPreset: this.config.webcamLayoutPreset,
webcamPosition: this.config.webcamPosition,
webcamMaskShape: this.config.webcamMaskShape,
});
if (!compositeLayout) return;
@@ -508,10 +522,16 @@ 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 },
{ connectZooms: true, cursorTelemetry: this.config.cursorTelemetry, viewportRatio },
);
const defaultFocus = DEFAULT_FOCUS;
@@ -527,6 +547,50 @@ export class FrameRenderer {
targetFocus = regionFocus;
targetProgress = strength;
// Apply adaptive smoothing for auto-follow mode
if (region.focusMode === "auto" && !transition) {
const raw = targetFocus;
const dtMs = this.prevAnimationTimeMs != null ? timeMs - this.prevAnimationTimeMs : 0;
const framesElapsed = dtMs > 0 ? dtMs / (1000 / 60) : 1;
const isZoomingIn = targetProgress < 0.999 && targetProgress >= this.prevTargetProgress;
if (targetProgress >= 0.999) {
// Full zoom: adaptive smoothing — moves faster when far, decelerates when close
const prev = this.smoothedAutoFocus ?? raw;
const baseFactor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const factor = 1 - Math.pow(1 - baseFactor, Math.max(1, framesElapsed));
const smoothed = smoothCursorFocus(raw, prev, factor);
this.smoothedAutoFocus = smoothed;
targetFocus = smoothed;
} else if (isZoomingIn) {
// Zoom-in: track cursor directly so zoom always aims at current cursor
// position; keep ref in sync to avoid snap when full-zoom begins
this.smoothedAutoFocus = raw;
} else {
// Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start
const prev = this.smoothedAutoFocus ?? raw;
const baseFactor = adaptiveSmoothFactor(
raw,
prev,
AUTO_FOLLOW_SMOOTHING_FACTOR,
AUTO_FOLLOW_SMOOTHING_FACTOR_MAX,
AUTO_FOLLOW_RAMP_DISTANCE,
);
const factor = 1 - Math.pow(1 - baseFactor, Math.max(1, framesElapsed));
const smoothed = smoothCursorFocus(raw, prev, factor);
this.smoothedAutoFocus = smoothed;
targetFocus = smoothed;
}
} else if (region.focusMode !== "auto") {
this.smoothedAutoFocus = null;
}
this.prevTargetProgress = targetProgress;
if (transition) {
const startTransform = computeZoomTransform({
stageSize: this.layoutCache.stageSize,
@@ -602,6 +666,8 @@ export class FrameRenderer {
state.y = appliedY;
state.appliedScale = appliedScale;
this.prevAnimationTimeMs = timeMs;
return Math.max(
Math.abs(appliedScale - prevScale),
Math.abs(appliedX - prevX) / Math.max(1, this.layoutCache.stageSize.width),
@@ -668,16 +734,17 @@ export class FrameRenderer {
const webcamRect = this.layoutCache?.webcamRect ?? null;
if (webcamFrame && webcamRect) {
const preset = getWebcamLayoutPresetDefinition(this.config.webcamLayoutPreset);
const shape = webcamRect.maskShape ?? this.config.webcamMaskShape ?? "rectangle";
ctx.save();
ctx.beginPath();
ctx.roundRect(
drawCanvasClipPath(
ctx,
webcamRect.x,
webcamRect.y,
webcamRect.width,
webcamRect.height,
shape,
webcamRect.borderRadius,
);
ctx.closePath();
if (preset.shadow) {
ctx.shadowColor = preset.shadow.color;
ctx.shadowBlur = preset.shadow.blur;
+4
View File
@@ -41,10 +41,12 @@ interface GifExporterConfig {
videoPadding?: number;
cropRegion: CropRegion;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
onProgress?: (progress: ExportProgress) => void;
}
@@ -141,11 +143,13 @@ export class GifExporter {
videoHeight: videoInfo.height,
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
webcamLayoutPreset: this.config.webcamLayoutPreset,
webcamMaskShape: this.config.webcamMaskShape,
webcamPosition: this.config.webcamPosition,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
cursorTelemetry: this.config.cursorTelemetry,
});
await this.renderer.initialize();
+14 -2
View File
@@ -32,10 +32,12 @@ interface VideoExporterConfig extends ExportConfig {
videoPadding?: number;
cropRegion: CropRegion;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
cursorTelemetry?: import("@/components/video-editor/types").CursorTelemetryPoint[];
onProgress?: (progress: ExportProgress) => void;
}
@@ -134,11 +136,13 @@ export class VideoExporter {
videoHeight: videoInfo.height,
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
webcamLayoutPreset: this.config.webcamLayoutPreset,
webcamMaskShape: this.config.webcamMaskShape,
webcamPosition: this.config.webcamPosition,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
previewHeight: this.config.previewHeight,
cursorTelemetry: this.config.cursorTelemetry,
});
this.renderer = renderer;
await renderer.initialize();
@@ -230,8 +234,16 @@ export class VideoExporter {
const canvas = renderer.getCanvas();
// @ts-expect-error - colorSpace is available at runtime even if TS does not know it.
const exportFrame = new VideoFrame(canvas, {
// 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: {
+15
View File
@@ -0,0 +1,15 @@
/** Duration of a single frame in seconds at 60 FPS (~16.67ms). */
export const FRAME_DURATION_SEC = 1 / 60;
/**
* Compute the new playhead time after stepping one frame forward or backward.
* The result is clamped to the range [0, duration].
*/
export function computeFrameStepTime(
currentTime: number,
duration: number,
direction: "forward" | "backward",
): number {
const delta = direction === "forward" ? FRAME_DURATION_SEC : -FRAME_DURATION_SEC;
return Math.min(duration, Math.max(0, currentTime + delta));
}
+25 -4
View File
@@ -21,14 +21,16 @@ export interface ShortcutBinding {
export type ShortcutsConfig = Record<ShortcutAction, ShortcutBinding>;
export interface FixedShortcut {
i18nKey: string;
label: string;
display: string;
bindings: ShortcutBinding[];
}
export const FIXED_SHORTCUTS: FixedShortcut[] = [
{ label: "Undo", display: "Ctrl + Z", bindings: [{ key: "z", ctrl: true }] },
{ i18nKey: "undo", label: "Undo", display: "Ctrl + Z", bindings: [{ key: "z", ctrl: true }] },
{
i18nKey: "redo",
label: "Redo",
display: "Ctrl + Shift + Z / Ctrl + Y",
bindings: [
@@ -36,19 +38,38 @@ export const FIXED_SHORTCUTS: FixedShortcut[] = [
{ key: "y", ctrl: true },
],
},
{ label: "Cycle Annotations Forward", display: "Tab", bindings: [{ key: "tab" }] },
{
i18nKey: "cycleAnnotationsForward",
label: "Cycle Annotations Forward",
display: "Tab",
bindings: [{ key: "tab" }],
},
{
i18nKey: "cycleAnnotationsBackward",
label: "Cycle Annotations Backward",
display: "Shift + Tab",
bindings: [{ key: "tab", shift: true }],
},
{
i18nKey: "deleteSelectedAlt",
label: "Delete Selected (alt)",
display: "Del / ⌫",
bindings: [{ key: "delete" }, { key: "backspace" }],
},
{ label: "Pan Timeline", display: "Shift + Ctrl + Scroll", bindings: [] },
{ label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] },
{
i18nKey: "panTimeline",
label: "Pan Timeline",
display: "Shift + Ctrl + Scroll",
bindings: [],
},
{ i18nKey: "zoomTimeline", label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] },
{ i18nKey: "frameBack", label: "Frame Back", display: "←", bindings: [{ key: "arrowleft" }] },
{
i18nKey: "frameForward",
label: "Frame Forward",
display: "→",
bindings: [{ key: "arrowright" }],
},
];
export type ShortcutConflict =
+48
View File
@@ -0,0 +1,48 @@
import type { WebcamMaskShape } from "@/components/video-editor/types";
/**
* Returns a CSS clip-path value for the given shape, or null if borderRadius alone suffices.
*/
export function getCssClipPath(shape: WebcamMaskShape): string | null {
switch (shape) {
case "circle":
return "circle(50% at 50% 50%)";
case "rectangle":
case "rounded":
case "square":
default:
return null;
}
}
/**
* Draws a Canvas 2D clip path for the given webcam mask shape.
* Call ctx.beginPath() is handled internally; caller should call ctx.clip() after.
*/
export function drawCanvasClipPath(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
shape: WebcamMaskShape,
borderRadius: number,
): void {
ctx.beginPath();
switch (shape) {
case "circle": {
const cx = x + w / 2;
const cy = y + h / 2;
const r = Math.min(w, h) / 2;
ctx.arc(cx, cy, r, 0, Math.PI * 2);
break;
}
case "rectangle":
case "rounded":
case "square":
default:
ctx.roundRect(x, y, w, h, borderRadius);
break;
}
ctx.closePath();
}
+15 -6
View File
@@ -58,14 +58,23 @@ test("exports a GIF from a loaded video", async () => {
);
});
await hudWindow.evaluate((videoPath: string) => {
window.electronAPI.setCurrentVideoPath(videoPath);
try {
try {
await hudWindow.evaluate(async (videoPath: string) => {
await window.electronAPI.setCurrentVideoPath(videoPath);
window.electronAPI.switchToEditor();
} catch {
// Expected: HUD window closes during this call, killing the context.
}, TEST_VIDEO);
} catch (error) {
// Expected: switchToEditor() closes the HUD window, which terminates
// the Playwright page context before evaluate() can resolve.
if (
!(
error instanceof Error &&
error.message.includes("Target page, context or browser has been closed")
)
) {
throw error;
}
}, TEST_VIDEO);
}
// ── 3. Switch to the editor window. This closes the HUD and opens
// a new BrowserWindow with ?windowType=editor.