Merge pull request #394 from wizardAEI/codex/feature/camera-crop

[codex] Add webcam crop and mirror controls
This commit is contained in:
Phạm Thị Minh Hồng
2026-05-06 16:06:40 +07:00
committed by GitHub
19 changed files with 1013 additions and 144 deletions
+80 -17
View File
@@ -97,7 +97,12 @@ import {
cursorSetAssets,
getCursorStyleSizeMultiplier,
} from "./videoPlayback/uploadedCursorAssets";
import { getWebcamPositionForPreset, resolveWebcamCorner } from "./webcamOverlay";
import { WebcamCropControl } from "./WebcamCropControl";
import {
getWebcamPositionForPreset,
normalizeWebcamCropRegion,
resolveWebcamCorner,
} from "./webcamOverlay";
const tahoeCursorUrl = cursorSetAssets.tahoe.arrow.url;
const BUILTIN_CURSOR_PREVIEW_SIZE = 28;
@@ -528,6 +533,9 @@ interface SettingsPanelProps {
borderRadius?: number;
onBorderRadiusChange?: (radius: number) => void;
webcam?: WebcamOverlaySettings;
webcamPreviewSrc?: string | null;
webcamPreviewCurrentTime?: number;
webcamPreviewPlaying?: boolean;
onWebcamChange?: (webcam: WebcamOverlaySettings) => void;
onUploadWebcam?: () => void;
onClearWebcam?: () => void;
@@ -896,6 +904,9 @@ export function SettingsPanel({
borderRadius = 12.5,
onBorderRadiusChange,
webcam,
webcamPreviewSrc = null,
webcamPreviewCurrentTime = 0,
webcamPreviewPlaying = false,
onWebcamChange,
onUploadWebcam,
onClearWebcam,
@@ -1346,6 +1357,7 @@ export function SettingsPanel({
const webcamPositionPreset = webcam?.positionPreset ?? DEFAULT_WEBCAM_POSITION_PRESET;
const webcamPositionX = webcam?.positionX ?? DEFAULT_WEBCAM_POSITION_X;
const webcamPositionY = webcam?.positionY ?? DEFAULT_WEBCAM_POSITION_Y;
const webcamCrop = normalizeWebcamCropRegion(webcam?.cropRegion);
const getWallpaperTileState = (candidateValue: string, previewPath?: string) => {
if (!selected) return false;
@@ -2725,7 +2737,8 @@ export function SettingsPanel({
<SliderControl
label={tSettings("effects.zoomMotionBlurSampleCount", "Sample count")}
value={
zoomMotionBlurSampleCount ?? TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT
zoomMotionBlurSampleCount ??
TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT
}
defaultValue={
initialEditorPreferences.zoomMotionBlurSampleCount ??
@@ -3018,7 +3031,10 @@ export function SettingsPanel({
<div className="space-y-1.5 rounded-lg border border-[#2563EB]/15 bg-[#2563EB]/5 px-3 py-3">
<div>
<div className="text-[11px] font-medium text-foreground">
{tSettings("effects.cursorDebugTuning", "Cursor Debug Tuning")}
{tSettings(
"effects.cursorDebugTuning",
"Cursor Debug Tuning",
)}
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">
{tSettings(
@@ -3033,7 +3049,9 @@ export function SettingsPanel({
"Spring stiffness",
)}
value={cursorSpringStiffnessMultiplier}
defaultValue={initialEditorPreferences.cursorSpringStiffnessMultiplier}
defaultValue={
initialEditorPreferences.cursorSpringStiffnessMultiplier
}
min={0.25}
max={3}
step={0.01}
@@ -3049,7 +3067,9 @@ export function SettingsPanel({
"Spring damping",
)}
value={cursorSpringDampingMultiplier}
defaultValue={initialEditorPreferences.cursorSpringDampingMultiplier}
defaultValue={
initialEditorPreferences.cursorSpringDampingMultiplier
}
min={0.25}
max={3}
step={0.01}
@@ -3065,11 +3085,15 @@ export function SettingsPanel({
"Spring mass",
)}
value={cursorSpringMassMultiplier}
defaultValue={initialEditorPreferences.cursorSpringMassMultiplier}
defaultValue={
initialEditorPreferences.cursorSpringMassMultiplier
}
min={0.25}
max={3}
step={0.01}
onChange={(value) => onCursorSpringMassMultiplierChange?.(value)}
onChange={(value) =>
onCursorSpringMassMultiplierChange?.(value)
}
formatValue={(value) => `${value.toFixed(2)}×`}
parseInput={(text) => parseFloat(text.replace(/×$/, ""))}
/>
@@ -3113,6 +3137,16 @@ export function SettingsPanel({
className="data-[state=checked]:bg-[#2563EB] scale-75"
/>
</div>
<div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5">
<span className="text-[10px] text-muted-foreground">
{tSettings("effects.webcamMirror", "Mirror webcam")}
</span>
<Switch
checked={webcam?.mirror ?? true}
onCheckedChange={(mirror) => updateWebcam({ mirror })}
className="data-[state=checked]:bg-[#2563EB] scale-75"
/>
</div>
<SliderControl
label={tSettings("effects.webcamSize")}
value={webcam?.size ?? DEFAULT_WEBCAM_SIZE}
@@ -3124,6 +3158,31 @@ export function SettingsPanel({
formatValue={(v) => `${Math.round(v)}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
/>
<div className="rounded-lg bg-foreground/[0.03] px-2.5 py-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[10px] text-muted-foreground">
{tSettings("effects.webcamCrop", "Crop")}
</div>
<button
type="button"
onClick={() =>
updateWebcam({ cropRegion: DEFAULT_CROP_REGION })
}
className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80"
>
{t("common.actions.reset", "Reset")}
</button>
</div>
<WebcamCropControl
cropRegion={webcamCrop}
mirrored={webcam?.mirror ?? true}
previewSrc={webcamPreviewSrc}
previewCurrentTime={webcamPreviewCurrentTime}
previewPlaying={webcamPreviewPlaying}
previewTimeOffsetMs={webcam?.timeOffsetMs}
onCropChange={(cropRegion) => updateWebcam({ cropRegion })}
/>
</div>
<div className="rounded-lg bg-foreground/[0.03] px-2.5 py-2">
<div className="mb-2 text-[10px] text-muted-foreground">
{tSettings("effects.webcamPosition", "Position")}
@@ -3238,37 +3297,41 @@ export function SettingsPanel({
parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100}
/>
<div className="rounded-lg bg-foreground/[0.03] px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<div>
<div className="flex flex-col gap-2">
<div className="min-w-0">
<div className="text-[10px] text-muted-foreground">
{tSettings("effects.webcamFootage")}
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground/70">
<div className="mt-0.5 break-all text-[10px] leading-4 text-muted-foreground/70">
{webcamFileName ??
tSettings("effects.webcamFootageDescription")}
</div>
</div>
<div className="flex items-center gap-1.5">
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
<Button
type="button"
variant="outline"
onClick={onUploadWebcam}
className="h-7 gap-1.5 border-foreground/10 bg-foreground/5 px-2 text-[10px] text-foreground hover:bg-foreground/10 hover:text-foreground"
className="h-7 min-w-0 gap-1.5 border-foreground/10 bg-foreground/5 px-2 text-[10px] text-foreground hover:bg-foreground/10 hover:text-foreground"
>
<Upload className="h-3 w-3" />
{webcam?.sourcePath
? tSettings("effects.replaceWebcamFootage")
: tSettings("effects.uploadWebcamFootage")}
<span className="min-w-0 truncate">
{webcam?.sourcePath
? tSettings("effects.replaceWebcamFootage")
: tSettings("effects.uploadWebcamFootage")}
</span>
</Button>
{webcam?.sourcePath ? (
<Button
type="button"
variant="outline"
onClick={onClearWebcam}
className="h-7 gap-1.5 border-foreground/10 bg-foreground/5 px-2 text-[10px] text-foreground hover:bg-foreground/10 hover:text-foreground"
className="h-7 min-w-0 gap-1.5 border-foreground/10 bg-foreground/5 px-2 text-[10px] text-foreground hover:bg-foreground/10 hover:text-foreground"
>
<Trash2 className="h-3 w-3" />
{tSettings("effects.removeWebcamFootage")}
<span className="min-w-0 truncate">
{tSettings("effects.removeWebcamFootage")}
</span>
</Button>
) : null}
</div>
+108 -79
View File
@@ -1,5 +1,5 @@
import {
BookmarkSimple,
BookmarkSimple,
Check,
CaretDown as ChevronDown,
CaretUp as ChevronUp,
@@ -74,13 +74,13 @@ import {
getMediaSyncPlaybackRate,
} from "@/lib/mediaTiming";
import { matchesShortcut } from "@/lib/shortcuts";
import { cn } from "@/lib/utils";
import {
ASPECT_RATIOS,
type AspectRatio,
getAspectRatioLabel,
getAspectRatioValue,
} from "@/utils/aspectRatioUtils";
import { cn } from "@/lib/utils";
import { ExtensionIcon } from "./ExtensionIcon";
const PhCursorFill = (props: { className?: string; weight?: "fill" | "regular" }) => (
@@ -108,13 +108,13 @@ import { CropControl } from "./CropControl";
import { ExportSettingsMenu } from "./ExportSettingsMenu";
import ExtensionManager from "./ExtensionManager";
import {
type EditorPreset,
type EditorPresetSnapshot,
loadEditorPreferences,
loadEditorPresets,
saveEditorPreferences,
saveEditorPresets,
serializeEditorPresetSnapshot,
type EditorPreset,
type EditorPresetSnapshot,
} from "./editorPreferences";
import ProjectBrowserDialog, { type ProjectLibraryEntry } from "./ProjectBrowserDialog";
import {
@@ -170,9 +170,9 @@ import {
extendAutoFullTrackClip,
type FigureData,
getClipSourceEndMs,
type Padding,
mapSourceTimeToTimelineTime as resolveSourceTimeToTimelineTime,
mapTimelineTimeToSourceTime as resolveTimelineTimeToSourceTime,
type Padding,
type SpeedRegion,
type TrimRegion,
trimsToClips,
@@ -1658,9 +1658,9 @@ export default function VideoEditor() {
shadowIntensity: number;
backgroundBlur: number;
zoomMotionBlur: number;
zoomTemporalMotionBlur: number;
zoomMotionBlurSampleCount: number | null;
zoomMotionBlurShutterFraction: number | null;
zoomTemporalMotionBlur: number;
zoomMotionBlurSampleCount: number | null;
zoomMotionBlurShutterFraction: number | null;
connectZooms: boolean;
zoomInDurationMs: number;
zoomInOverlapMs: number;
@@ -2029,24 +2029,27 @@ export default function VideoEditor() {
setCurrentTime(0);
setDuration(0);
setError(null);
setVideoSourcePath(sourcePath);
setVideoPath(await resolveVideoUrl(sourcePath));
setCurrentProjectPath(path ?? null);
pendingFreshRecordingAutoZoomPathRef.current = null;
if (normalizedEditor.webcam.sourcePath) {
await window.electronAPI.setCurrentRecordingSession?.({
setError(null);
setVideoSourcePath(sourcePath);
setVideoPath(await resolveVideoUrl(sourcePath));
setCurrentProjectPath(path ?? null);
pendingFreshRecordingAutoZoomPathRef.current = null;
if (normalizedEditor.webcam.sourcePath) {
await window.electronAPI.setCurrentRecordingSession?.(
{
videoPath: sourcePath,
webcamPath: normalizedEditor.webcam.sourcePath,
timeOffsetMs: normalizedEditor.webcam.timeOffsetMs,
}, {
},
{
preserveProjectPath: Boolean(path),
});
} else {
await window.electronAPI.setCurrentVideoPath(sourcePath, {
preserveProjectPath: Boolean(path),
});
}
},
);
} else {
await window.electronAPI.setCurrentVideoPath(sourcePath, {
preserveProjectPath: Boolean(path),
});
}
setWallpaper(normalizedEditor.wallpaper);
setShadowIntensity(normalizedEditor.shadowIntensity);
@@ -2070,12 +2073,8 @@ export default function VideoEditor() {
setCursorStyle(normalizedEditor.cursorStyle);
setCursorSize(normalizedEditor.cursorSize);
setCursorSmoothing(normalizedEditor.cursorSmoothing);
setCursorSpringStiffnessMultiplier(
normalizedEditor.cursorSpringStiffnessMultiplier,
);
setCursorSpringDampingMultiplier(
normalizedEditor.cursorSpringDampingMultiplier,
);
setCursorSpringStiffnessMultiplier(normalizedEditor.cursorSpringStiffnessMultiplier);
setCursorSpringDampingMultiplier(normalizedEditor.cursorSpringDampingMultiplier);
setCursorSpringMassMultiplier(normalizedEditor.cursorSpringMassMultiplier);
setZoomSmoothness(normalizedEditor.zoomSmoothness);
setZoomClassicMode(normalizedEditor.zoomClassicMode);
@@ -2175,18 +2174,21 @@ export default function VideoEditor() {
return;
}
await window.electronAPI.setCurrentRecordingSession({
videoPath: currentSourcePath,
webcamPath,
timeOffsetMs:
webcamPath && Number.isFinite(timeOffsetMs)
? (timeOffsetMs ?? DEFAULT_WEBCAM_TIME_OFFSET_MS)
: webcamPath
? webcam.timeOffsetMs
: DEFAULT_WEBCAM_TIME_OFFSET_MS,
}, {
preserveProjectPath: Boolean(currentProjectPath),
});
await window.electronAPI.setCurrentRecordingSession(
{
videoPath: currentSourcePath,
webcamPath,
timeOffsetMs:
webcamPath && Number.isFinite(timeOffsetMs)
? (timeOffsetMs ?? DEFAULT_WEBCAM_TIME_OFFSET_MS)
: webcamPath
? webcam.timeOffsetMs
: DEFAULT_WEBCAM_TIME_OFFSET_MS,
},
{
preserveProjectPath: Boolean(currentProjectPath),
},
);
},
[currentProjectPath, currentSourcePath, webcam.timeOffsetMs],
);
@@ -2194,13 +2196,16 @@ export default function VideoEditor() {
const syncActiveVideoSource = useCallback(
async (sourcePath: string, webcamPath?: string | null) => {
if (webcamPath) {
await window.electronAPI.setCurrentRecordingSession?.({
videoPath: sourcePath,
webcamPath,
timeOffsetMs: webcam.timeOffsetMs,
}, {
preserveProjectPath: Boolean(currentProjectPath),
});
await window.electronAPI.setCurrentRecordingSession?.(
{
videoPath: sourcePath,
webcamPath,
timeOffsetMs: webcam.timeOffsetMs,
},
{
preserveProjectPath: Boolean(currentProjectPath),
},
);
return;
}
@@ -2761,10 +2766,13 @@ export default function VideoEditor() {
.split(/[\\/]/)
.pop()
?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`;
let targetProjectPath = forceSaveAs ? undefined : (currentProjectPath ?? undefined);
let targetProjectPath = forceSaveAs
? undefined
: (currentProjectPath ?? undefined);
if (!forceSaveAs && !targetProjectPath) {
const activeProjectResult = await window.electronAPI.loadCurrentProjectFile();
const activeProjectResult =
await window.electronAPI.loadCurrentProjectFile();
if (activeProjectResult.success && activeProjectResult.path) {
targetProjectPath = activeProjectResult.path;
setCurrentProjectPath(activeProjectResult.path);
@@ -3166,11 +3174,11 @@ export default function VideoEditor() {
trimRegions.length > 0
? trimsToClips(trimRegions, totalMs)
: (() => {
const id = `clip-${nextClipIdRef.current++}`;
autoFullTrackClipIdRef.current = id;
autoFullTrackClipEndMsRef.current = totalMs;
return [{ id, startMs: 0, endMs: totalMs, speed: 1 }];
})();
const id = `clip-${nextClipIdRef.current++}`;
autoFullTrackClipIdRef.current = id;
autoFullTrackClipEndMsRef.current = totalMs;
return [{ id, startMs: 0, endMs: totalMs, speed: 1 }];
})();
if (trimRegions.length > 0) {
nextClipIdRef.current = deriveNextId(
@@ -3420,7 +3428,7 @@ export default function VideoEditor() {
endMs: Math.round(span.end),
}
: region,
),
),
);
}, []);
@@ -3700,22 +3708,25 @@ export default function VideoEditor() {
);
}, []);
const handleAudioVolumeChange = useCallback((volume: number) => {
if (!selectedAudioId) {
return;
}
const handleAudioVolumeChange = useCallback(
(volume: number) => {
if (!selectedAudioId) {
return;
}
if (!Number.isFinite(volume)) {
return;
}
if (!Number.isFinite(volume)) {
return;
}
const nextVolume = Math.max(0, Math.min(1, volume));
setAudioRegions((prev) =>
prev.map((region) =>
region.id === selectedAudioId ? { ...region, volume: nextVolume } : region,
),
);
}, [selectedAudioId]);
const nextVolume = Math.max(0, Math.min(1, volume));
setAudioRegions((prev) =>
prev.map((region) =>
region.id === selectedAudioId ? { ...region, volume: nextVolume } : region,
),
);
},
[selectedAudioId],
);
const handleAudioDelete = useCallback(
(id: string) => {
@@ -4234,8 +4245,8 @@ export default function VideoEditor() {
isPlaying,
previewSourceAudioFallbackPaths,
sourceAudioFallbackStartDelayMsByPath,
effectiveSpeedRegions,
]);
effectiveSpeedRegions,
]);
const showExportSuccessToast = useCallback((filePath: string) => {
toast.success(`Exported successfully to ${filePath}`, {
@@ -5316,7 +5327,8 @@ export default function VideoEditor() {
<span className="flex items-center gap-1.5">
<BookmarkSimple weight="fill" className="h-4 w-4" />
<span>
{currentEditorPreset?.name ?? t("editor.presets.label", "Presets")}
{currentEditorPreset?.name ??
t("editor.presets.label", "Presets")}
</span>
</span>
<ChevronDown className="h-3.5 w-3.5 text-foreground" />
@@ -5336,12 +5348,17 @@ export default function VideoEditor() {
className="space-y-2"
>
<p className="text-[11px] font-medium text-foreground">
{t("editor.presets.saveCurrentAs", "Save current preset as")}
{t(
"editor.presets.saveCurrentAs",
"Save current preset as",
)}
</p>
<div className="flex items-center gap-2">
<Input
value={presetNameDraft}
onChange={(event) => setPresetNameDraft(event.target.value)}
onChange={(event) =>
setPresetNameDraft(event.target.value)
}
className="h-9 rounded-xl border-foreground/10 bg-background/70 text-sm"
placeholder={t(
"editor.presets.namePlaceholder",
@@ -5373,7 +5390,8 @@ export default function VideoEditor() {
</div>
) : (
editorPresets.map((preset) => {
const isActive = preset.id === currentEditorPreset?.id;
const isActive =
preset.id === currentEditorPreset?.id;
return (
<div
key={preset.id}
@@ -5386,17 +5404,23 @@ export default function VideoEditor() {
>
<button
type="button"
onClick={() => handleApplyEditorPreset(preset.id)}
onClick={() =>
handleApplyEditorPreset(preset.id)
}
className="flex min-w-0 flex-1 items-center justify-between text-left"
>
<span className="truncate pr-3">{preset.name}</span>
<span className="truncate pr-3">
{preset.name}
</span>
{isActive ? (
<Check className="h-3.5 w-3.5 shrink-0 text-[#2563EB]" />
) : null}
</button>
<button
type="button"
onClick={() => handleDeleteEditorPreset(preset.id)}
onClick={() =>
handleDeleteEditorPreset(preset.id)
}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-foreground/8 hover:text-foreground"
aria-label={t(
"editor.presets.deleteAriaLabel",
@@ -5829,6 +5853,9 @@ export default function VideoEditor() {
borderRadius={borderRadius}
onBorderRadiusChange={setBorderRadius}
webcam={webcam}
webcamPreviewSrc={webcam.sourcePath ? resolvedWebcamVideoUrl : null}
webcamPreviewCurrentTime={currentTime}
webcamPreviewPlaying={isPlaying}
onWebcamChange={setWebcam}
onUploadWebcam={handleUploadWebcam}
onClearWebcam={handleClearWebcam}
@@ -6016,7 +6043,9 @@ export default function VideoEditor() {
cursorSpringDampingMultiplier={
cursorSpringDampingMultiplier
}
cursorSpringMassMultiplier={cursorSpringMassMultiplier}
cursorSpringMassMultiplier={
cursorSpringMassMultiplier
}
zoomSmoothness={zoomSmoothness}
zoomClassicMode={zoomClassicMode}
cursorMotionBlur={cursorMotionBlur}
+136 -33
View File
@@ -126,7 +126,11 @@ import {
createMotionBlurState,
type MotionBlurState,
} from "./videoPlayback/zoomTransform";
import { getWebcamOverlayPosition, getWebcamOverlaySizePx } from "./webcamOverlay";
import {
getWebcamCropSourceRect,
getWebcamOverlayPosition,
getWebcamOverlaySizePx,
} from "./webcamOverlay";
type PlaybackAnimationState = {
scale: number;
@@ -358,6 +362,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
const webcamBubbleRef = useRef<HTMLDivElement | null>(null);
const webcamBubbleInnerRef = useRef<HTMLDivElement | null>(null);
const [webcamVideoDimensions, setWebcamVideoDimensions] = useState<{
width: number;
height: number;
} | null>(null);
const captionBoxRef = useRef<HTMLDivElement | null>(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
@@ -509,37 +517,74 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return () => cancelAnimationFrame(frame);
}, [activeCaptionLayout, autoCaptionSettings]);
const motionBlurStateRef = useRef<MotionBlurState>(createMotionBlurState());
const webcamEnabled = webcam?.enabled ?? false;
const webcamMargin = webcam?.margin ?? 24;
const webcamSize = webcam?.size ?? DEFAULT_WEBCAM_SIZE;
const webcamReactToZoom = webcam?.reactToZoom ?? DEFAULT_WEBCAM_REACT_TO_ZOOM;
const webcamPositionPreset = webcam?.positionPreset ?? webcam?.corner ?? "bottom-right";
const webcamPositionX = webcam?.positionX ?? 1;
const webcamPositionY = webcam?.positionY ?? 1;
const webcamCorner = webcam?.corner ?? "bottom-right";
const webcamCornerRadius = webcam?.cornerRadius ?? DEFAULT_WEBCAM_CORNER_RADIUS;
const webcamShadow = webcam?.shadow ?? DEFAULT_WEBCAM_SHADOW;
const webcamTimeOffsetMs = webcam?.timeOffsetMs;
const webcamCropRegion = webcam?.cropRegion;
const webcamMirror = webcam?.mirror ?? false;
const webcamCropPreviewContentStyle = useMemo<React.CSSProperties>(() => {
if (!webcamVideoDimensions) {
return { opacity: 0 };
}
const { sx, sy, sw, sh } = getWebcamCropSourceRect(
webcamCropRegion,
webcamVideoDimensions.width,
webcamVideoDimensions.height,
);
const coverScale = Math.max(1 / sw, 1 / sh);
const drawWidth = webcamVideoDimensions.width * coverScale;
const drawHeight = webcamVideoDimensions.height * coverScale;
const drawX = (1 - sw * coverScale) / 2 - sx * coverScale;
const drawY = (1 - sh * coverScale) / 2 - sy * coverScale;
return {
left: `${drawX * 100}%`,
top: `${drawY * 100}%`,
width: `${drawWidth * 100}%`,
height: `${drawHeight * 100}%`,
maxWidth: "none",
willChange: "left, top, width, height",
};
}, [webcamCropRegion, webcamVideoDimensions]);
const applyWebcamBubbleLayout = useCallback(
(zoomScale: number) => {
const bubble = webcamBubbleRef.current;
const bubbleInner = webcamBubbleInnerRef.current;
const overlay = overlayRef.current;
if (!bubble || !bubbleInner || !overlay || !webcam?.enabled || !webcamVideoPath) {
if (!bubble || !bubbleInner || !overlay || !webcamEnabled || !webcamVideoPath) {
if (bubble) {
bubble.style.display = "none";
}
return;
}
const margin = webcam.margin ?? 24;
const scaledSize = getWebcamOverlaySizePx({
containerWidth: overlay.clientWidth,
containerHeight: overlay.clientHeight,
sizePercent: webcam.size ?? DEFAULT_WEBCAM_SIZE,
margin,
sizePercent: webcamSize,
margin: webcamMargin,
zoomScale,
reactToZoom: webcam.reactToZoom ?? DEFAULT_WEBCAM_REACT_TO_ZOOM,
reactToZoom: webcamReactToZoom,
});
const { x, y } = getWebcamOverlayPosition({
containerWidth: overlay.clientWidth,
containerHeight: overlay.clientHeight,
size: scaledSize,
margin,
positionPreset: webcam.positionPreset ?? webcam.corner,
positionX: webcam.positionX ?? 1,
positionY: webcam.positionY ?? 1,
legacyCorner: webcam.corner,
margin: webcamMargin,
positionPreset: webcamPositionPreset,
positionX: webcamPositionX,
positionY: webcamPositionY,
legacyCorner: webcamCorner,
});
bubble.style.display = "block";
@@ -547,24 +592,39 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
bubble.style.top = `${y}px`;
bubble.style.width = `${scaledSize}px`;
bubble.style.height = `${scaledSize}px`;
bubble.style.aspectRatio = "1 / 1";
const squirclePath = getSquircleSvgPath({
x: 0,
y: 0,
width: scaledSize,
height: scaledSize,
radius: webcam.cornerRadius ?? DEFAULT_WEBCAM_CORNER_RADIUS,
radius: webcamCornerRadius,
});
bubble.style.filter = `drop-shadow(0 ${Math.round(scaledSize * 0.06)}px ${Math.round(
scaledSize * 0.22,
)}px rgba(0, 0, 0, ${webcam.shadow ?? DEFAULT_WEBCAM_SHADOW}))`;
)}px rgba(0, 0, 0, ${webcamShadow}))`;
bubble.style.borderRadius = "0px";
bubble.style.boxShadow = "none";
bubbleInner.style.borderRadius = "0px";
bubbleInner.style.overflow = "hidden";
bubbleInner.style.contain = "paint";
bubbleInner.style.clipPath = `path('${squirclePath}')`;
bubbleInner.style.setProperty("-webkit-clip-path", `path('${squirclePath}')`);
},
[webcam, webcamVideoPath],
[
webcamCorner,
webcamCornerRadius,
webcamEnabled,
webcamMargin,
webcamPositionPreset,
webcamPositionX,
webcamPositionY,
webcamReactToZoom,
webcamShadow,
webcamSize,
webcamVideoPath,
],
);
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
@@ -1320,22 +1380,30 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
useEffect(() => {
if (!pixiReady || !videoReady) return;
applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1);
}, [applyWebcamBubbleLayout, pixiReady, videoReady, webcam, webcamVideoPath]);
}, [applyWebcamBubbleLayout, pixiReady, videoReady]);
useEffect(() => {
const syncWebcamMedia = useCallback(() => {
const webcamVideo = webcamVideoRef.current;
if (!webcamVideo || !webcam?.enabled || !webcamVideoPath) {
if (!webcamVideo || !webcamEnabled || !webcamVideoPath) {
return;
}
const webcamDuration = Number.isFinite(webcamVideo.duration)
? webcamVideo.duration
: null;
const targetTime = getWebcamMediaTargetTimeSeconds({
currentTime,
webcamDuration: Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null,
timeOffsetMs: webcam.timeOffsetMs,
webcamDuration,
timeOffsetMs: webcamTimeOffsetMs,
});
const mediaTargetTime =
targetTime <= 0 && webcamDuration !== null && webcamDuration > 0
? Math.min(1 / 60, webcamDuration)
: targetTime;
const timelineTimeMs = currentTime * 1000;
const activeSpeedRegion = speedRegionsRef.current.find(
(region) => targetTime * 1000 >= region.startMs && targetTime * 1000 < region.endMs,
(region) => timelineTimeMs >= region.startMs && timelineTimeMs < region.endMs,
);
const targetPlaybackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
if (Math.abs(webcamVideo.playbackRate - targetPlaybackRate) > 0.001) {
@@ -1346,9 +1414,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const timelineJumped =
previousTimelineTime === null || Math.abs(targetTime - previousTimelineTime) > 0.25;
const driftThreshold = isPlaying ? 0.35 : 0.01;
if (timelineJumped || Math.abs(webcamVideo.currentTime - targetTime) > driftThreshold) {
if (
timelineJumped ||
Math.abs(webcamVideo.currentTime - mediaTargetTime) > driftThreshold
) {
try {
webcamVideo.currentTime = targetTime;
webcamVideo.currentTime = mediaTargetTime;
} catch {
// no-op
}
@@ -1364,9 +1435,28 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
lastWebcamSyncTimeRef.current = targetTime;
}, [currentTime, isPlaying, webcam, webcamVideoPath]);
}, [currentTime, isPlaying, webcamEnabled, webcamTimeOffsetMs, webcamVideoPath]);
const handleWebcamMediaReady = useCallback(
(event: React.SyntheticEvent<HTMLVideoElement>) => {
const video = event.currentTarget;
if (video.videoWidth > 0 && video.videoHeight > 0 && video.readyState >= 2) {
setWebcamVideoDimensions({
width: video.videoWidth,
height: video.videoHeight,
});
}
syncWebcamMedia();
},
[syncWebcamMedia],
);
useEffect(() => {
syncWebcamMedia();
}, [syncWebcamMedia]);
useEffect(() => {
setWebcamVideoDimensions(null);
lastWebcamSyncTimeRef.current = null;
}, [webcamVideoPath]);
@@ -2378,19 +2468,32 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
>
<div
ref={webcamBubbleInnerRef}
className="h-full w-full overflow-hidden bg-black/80"
className="relative h-full w-full overflow-hidden"
>
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className="h-full w-full object-cover"
muted
playsInline
preload="auto"
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{
transform: webcam.mirror ? "scaleX(-1)" : undefined,
opacity: webcamVideoDimensions ? 1 : 0,
transform: webcamMirror ? "scaleX(-1)" : undefined,
}}
/>
>
<div
className="pointer-events-none absolute"
style={webcamCropPreviewContentStyle}
>
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className="pointer-events-none absolute inset-0 block h-full w-full object-fill"
muted
playsInline
preload="auto"
aria-hidden="true"
onLoadedMetadata={handleWebcamMediaReady}
onLoadedData={handleWebcamMediaReady}
/>
</div>
</div>
</div>
</div>
) : null}
@@ -0,0 +1,512 @@
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import type { CropRegion } from "./types";
import { getWebcamPreviewTargetTimeSeconds } from "./videoPlayback/webcamSync";
import { normalizeWebcamCropRegion } from "./webcamOverlay";
type CropHandle = "move" | "nw" | "ne" | "sw" | "se";
const HANDLE_LABELS: Record<CropHandle, string> = {
move: "Move webcam crop",
nw: "Resize webcam crop from top left",
ne: "Resize webcam crop from top right",
sw: "Resize webcam crop from bottom left",
se: "Resize webcam crop from bottom right",
};
interface WebcamCropControlProps {
cropRegion: CropRegion;
mirrored?: boolean;
previewSrc?: string | null;
previewCurrentTime?: number;
previewPlaying?: boolean;
previewTimeOffsetMs?: number | null;
onCropChange: (cropRegion: CropRegion) => void;
}
interface DragState {
handle: CropHandle;
startX: number;
startY: number;
initialCrop: CropRegion;
}
interface PreviewFrame {
src: string | null;
width: number;
height: number;
}
const MIN_CROP_SIZE = 0.08;
const KEYBOARD_STEP = 0.01;
const KEYBOARD_FAST_STEP = 0.05;
const RESIZE_HANDLES: Array<{
handle: Exclude<CropHandle, "move">;
className: string;
cursorClassName: string;
}> = [
{
handle: "nw",
className: "left-0 top-0 -translate-x-1/2 -translate-y-1/2",
cursorClassName: "cursor-nwse-resize",
},
{
handle: "ne",
className: "right-0 top-0 translate-x-1/2 -translate-y-1/2",
cursorClassName: "cursor-nesw-resize",
},
{
handle: "se",
className: "bottom-0 right-0 translate-x-1/2 translate-y-1/2",
cursorClassName: "cursor-nwse-resize",
},
{
handle: "sw",
className: "bottom-0 left-0 -translate-x-1/2 translate-y-1/2",
cursorClassName: "cursor-nesw-resize",
},
];
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function normalizeAspectCropRegion(cropRegion: CropRegion, displayAspectRatio: number): CropRegion {
const crop = normalizeWebcamCropRegion(cropRegion);
const aspectRatio =
Number.isFinite(displayAspectRatio) && displayAspectRatio > 0 ? displayAspectRatio : 1;
const maxWidth = Math.min(1, 1 / aspectRatio);
const minWidth = Math.min(MIN_CROP_SIZE, maxWidth);
const width = clamp(Math.min(crop.width, crop.height / aspectRatio), minWidth, maxWidth);
const height = width * aspectRatio;
const centerX = crop.x + crop.width / 2;
const centerY = crop.y + crop.height / 2;
const x = clamp(centerX - width / 2, 0, 1 - width);
const y = clamp(centerY - height / 2, 0, 1 - height);
return { x, y, width, height };
}
function flipCropHorizontally(cropRegion: CropRegion, displayAspectRatio: number): CropRegion {
const crop = normalizeAspectCropRegion(cropRegion, displayAspectRatio);
return {
...crop,
x: clamp(1 - crop.x - crop.width, 0, 1 - crop.width),
};
}
function resizeCrop(
cropRegion: CropRegion,
handle: CropHandle,
deltaX: number,
deltaY: number,
displayAspectRatio: number,
) {
const aspectRatio =
Number.isFinite(displayAspectRatio) && displayAspectRatio > 0 ? displayAspectRatio : 1;
const crop = normalizeAspectCropRegion(cropRegion, aspectRatio);
const minWidth = Math.min(MIN_CROP_SIZE, Math.min(1, 1 / aspectRatio));
if (handle === "move") {
return normalizeAspectCropRegion(
{
...crop,
x: clamp(crop.x + deltaX, 0, 1 - crop.width),
y: clamp(crop.y + deltaY, 0, 1 - crop.height),
},
aspectRatio,
);
}
let left = crop.x;
let top = crop.y;
let right = crop.x + crop.width;
let bottom = crop.y + crop.height;
if (handle === "nw") {
const delta = Math.max(deltaX, deltaY / aspectRatio);
const nextWidth = clamp(
crop.width - delta,
minWidth,
Math.min(right, bottom / aspectRatio),
);
left = right - nextWidth;
top = bottom - nextWidth * aspectRatio;
}
if (handle === "ne") {
const delta = Math.max(deltaX, -deltaY / aspectRatio);
const nextWidth = clamp(
crop.width + delta,
minWidth,
Math.min(1 - left, bottom / aspectRatio),
);
right = left + nextWidth;
top = bottom - nextWidth * aspectRatio;
}
if (handle === "sw") {
const delta = Math.max(-deltaX, deltaY / aspectRatio);
const nextWidth = clamp(
crop.width + delta,
minWidth,
Math.min(right, (1 - top) / aspectRatio),
);
left = right - nextWidth;
bottom = top + nextWidth * aspectRatio;
}
if (handle === "se") {
const delta = Math.max(deltaX, deltaY / aspectRatio);
const nextWidth = clamp(
crop.width + delta,
minWidth,
Math.min(1 - left, (1 - top) / aspectRatio),
);
right = left + nextWidth;
bottom = top + nextWidth * aspectRatio;
}
return normalizeAspectCropRegion(
{
x: left,
y: top,
width: right - left,
height: bottom - top,
},
aspectRatio,
);
}
export function WebcamCropControl({
cropRegion,
mirrored = false,
previewSrc = null,
previewCurrentTime = 0,
previewPlaying = false,
previewTimeOffsetMs = 0,
onCropChange,
}: WebcamCropControlProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const pendingCropRef = useRef<CropRegion | null>(null);
const pendingFrameRef = useRef<number | null>(null);
const maskId = `webcam-crop-mask-${useId().replace(/:/g, "")}`;
const [activeHandle, setActiveHandle] = useState<CropHandle | null>(null);
const [draftVisualCrop, setDraftVisualCrop] = useState<CropRegion | null>(null);
const [previewFrame, setPreviewFrame] = useState<PreviewFrame | null>(null);
const hasPreviewFrame = previewSrc !== null && previewFrame?.src === previewSrc;
const previewAspectRatio =
hasPreviewFrame && previewFrame && previewFrame.width > 0 && previewFrame.height > 0
? previewFrame.width / previewFrame.height
: 1;
const sourceCrop = normalizeAspectCropRegion(cropRegion, previewAspectRatio);
const propVisualCrop = mirrored
? flipCropHorizontally(sourceCrop, previewAspectRatio)
: sourceCrop;
const crop = draftVisualCrop ?? propVisualCrop;
const cropLeft = crop.x * 100;
const cropTop = crop.y * 100;
const cropWidth = crop.width * 100;
const cropHeight = crop.height * 100;
const cancelPendingCommit = () => {
if (pendingFrameRef.current !== null) {
cancelAnimationFrame(pendingFrameRef.current);
pendingFrameRef.current = null;
}
};
const flushPendingCommit = () => {
const nextCrop = pendingCropRef.current;
if (!nextCrop) {
return;
}
cancelPendingCommit();
pendingCropRef.current = null;
onCropChange(nextCrop);
};
const syncPreviewMedia = useCallback(() => {
const video = previewVideoRef.current;
if (!video || !previewSrc) {
return;
}
const webcamDuration = Number.isFinite(video.duration) ? video.duration : null;
const targetTime = getWebcamPreviewTargetTimeSeconds({
currentTime: previewCurrentTime,
webcamDuration,
timeOffsetMs: previewTimeOffsetMs,
});
const mediaTargetTime =
targetTime <= 0 && webcamDuration !== null && webcamDuration > 0
? Math.min(1 / 60, webcamDuration)
: targetTime;
const driftThreshold = previewPlaying ? 0.35 : 0.01;
if (Math.abs(video.currentTime - mediaTargetTime) > driftThreshold) {
try {
video.currentTime = mediaTargetTime;
} catch {
/* Ignore browsers that reject seeks while metadata is settling. */
}
}
if (previewPlaying) {
const playPromise = video.play();
if (playPromise) {
playPromise.catch(() => undefined);
}
} else {
video.pause();
}
}, [previewCurrentTime, previewPlaying, previewSrc, previewTimeOffsetMs]);
const handlePreviewFrameReady = useCallback(() => {
const video = previewVideoRef.current;
if (video && video.videoWidth > 0 && video.videoHeight > 0 && video.readyState >= 2) {
setPreviewFrame({
src: previewSrc,
width: video.videoWidth,
height: video.videoHeight,
});
}
syncPreviewMedia();
}, [previewSrc, syncPreviewMedia]);
useEffect(() => {
syncPreviewMedia();
}, [syncPreviewMedia]);
const commitVisualCrop = (nextVisualCrop: CropRegion, immediate = false) => {
const nextCrop = mirrored
? flipCropHorizontally(nextVisualCrop, previewAspectRatio)
: nextVisualCrop;
if (immediate) {
cancelPendingCommit();
pendingCropRef.current = null;
onCropChange(nextCrop);
return;
}
pendingCropRef.current = nextCrop;
if (pendingFrameRef.current !== null) {
return;
}
pendingFrameRef.current = requestAnimationFrame(() => {
pendingFrameRef.current = null;
flushPendingCommit();
});
};
useEffect(() => {
return () => {
if (pendingFrameRef.current !== null) {
cancelAnimationFrame(pendingFrameRef.current);
}
};
}, []);
const getPointerPosition = (event: React.PointerEvent<HTMLDivElement>) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) {
return null;
}
return {
x: clamp((event.clientX - rect.left) / rect.width, 0, 1),
y: clamp((event.clientY - rect.top) / rect.height, 0, 1),
};
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>, handle: CropHandle) => {
const pointer = getPointerPosition(event);
if (!pointer) {
return;
}
event.preventDefault();
event.stopPropagation();
dragStateRef.current = {
handle,
startX: pointer.x,
startY: pointer.y,
initialCrop: crop,
};
setActiveHandle(handle);
try {
containerRef.current?.setPointerCapture(event.pointerId);
} catch {
/* Pointer capture can fail if the drag started outside the control. */
}
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current;
if (!dragState) {
return;
}
const pointer = getPointerPosition(event);
if (!pointer) {
return;
}
const nextVisualCrop = resizeCrop(
dragState.initialCrop,
dragState.handle,
pointer.x - dragState.startX,
pointer.y - dragState.startY,
previewAspectRatio,
);
setDraftVisualCrop(nextVisualCrop);
commitVisualCrop(nextVisualCrop);
};
const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
if (dragStateRef.current) {
try {
containerRef.current?.releasePointerCapture(event.pointerId);
} catch {
/* Pointer capture may already be released while ending the drag. */
}
}
flushPendingCommit();
dragStateRef.current = null;
setDraftVisualCrop(null);
setActiveHandle(null);
};
const getKeyboardDelta = (event: React.KeyboardEvent<HTMLElement>) => {
const step = event.shiftKey ? KEYBOARD_FAST_STEP : KEYBOARD_STEP;
if (event.key === "ArrowLeft") {
return { x: -step, y: 0 };
}
if (event.key === "ArrowRight") {
return { x: step, y: 0 };
}
if (event.key === "ArrowUp") {
return { x: 0, y: -step };
}
if (event.key === "ArrowDown") {
return { x: 0, y: step };
}
return null;
};
const handleKeyboardAdjust = (event: React.KeyboardEvent<HTMLElement>, handle: CropHandle) => {
const delta = getKeyboardDelta(event);
if (!delta) {
return;
}
event.preventDefault();
event.stopPropagation();
commitVisualCrop(resizeCrop(crop, handle, delta.x, delta.y, previewAspectRatio), true);
};
return (
<div
ref={containerRef}
className="relative w-full touch-none select-none overflow-hidden rounded-lg border border-foreground/10 bg-editor-dialog-alt"
style={{ aspectRatio: previewAspectRatio }}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
{previewSrc ? (
<video
ref={previewVideoRef}
src={previewSrc}
className="pointer-events-none absolute inset-0 block h-full w-full object-fill"
style={{
opacity: hasPreviewFrame ? 1 : 0,
transform: mirrored ? "scaleX(-1)" : undefined,
}}
muted
playsInline
preload="auto"
aria-hidden="true"
onLoadedMetadata={handlePreviewFrameReady}
onLoadedData={handlePreviewFrameReady}
onSeeked={handlePreviewFrameReady}
/>
) : null}
<div
className={cn(
"absolute inset-0 bg-editor-dialog-alt transition-opacity",
previewSrc && hasPreviewFrame ? "opacity-0" : "opacity-100",
)}
/>
<svg className="pointer-events-none absolute inset-0 h-full w-full">
<defs>
<mask id={maskId}>
<rect width="100%" height="100%" fill="white" />
<rect
x={`${cropLeft}%`}
y={`${cropTop}%`}
width={`${cropWidth}%`}
height={`${cropHeight}%`}
fill="black"
/>
</mask>
</defs>
<rect
width="100%"
height="100%"
fill="black"
fillOpacity="0.58"
mask={`url(#${maskId})`}
/>
</svg>
<div
className={cn(
"absolute border border-white shadow-[0_0_0_1px_rgba(37,99,235,0.9),0_8px_24px_rgba(0,0,0,0.25)] focus:outline-none focus:ring-2 focus:ring-[#2563EB]/60 focus:ring-offset-2 focus:ring-offset-editor-dialog",
activeHandle === "move" ? "cursor-grabbing" : "cursor-move",
)}
style={{
left: `${cropLeft}%`,
top: `${cropTop}%`,
width: `${cropWidth}%`,
height: `${cropHeight}%`,
}}
tabIndex={0}
aria-label={HANDLE_LABELS.move}
onPointerDown={(event) => handlePointerDown(event, "move")}
onFocus={() => setActiveHandle("move")}
onBlur={() => setActiveHandle(null)}
onKeyDown={(event) => handleKeyboardAdjust(event, "move")}
>
<div className="pointer-events-none absolute left-1/3 top-0 h-full w-px bg-white/45" />
<div className="pointer-events-none absolute left-2/3 top-0 h-full w-px bg-white/45" />
<div className="pointer-events-none absolute left-0 top-1/3 h-px w-full bg-white/45" />
<div className="pointer-events-none absolute left-0 top-2/3 h-px w-full bg-white/45" />
{RESIZE_HANDLES.map((handle) => (
<div
key={handle.handle}
role="slider"
tabIndex={0}
aria-label={HANDLE_LABELS[handle.handle]}
aria-valuemin={Math.round(MIN_CROP_SIZE * 100)}
aria-valuemax={100}
aria-valuenow={Math.round(crop.width * 100)}
aria-valuetext={`${Math.round(crop.width * 100)}%`}
className={cn(
"absolute z-10 h-3.5 w-3.5 rounded-[3px] border-2 border-white bg-[#2563EB] shadow-sm focus:outline-none focus:ring-2 focus:ring-[#2563EB]/60 focus:ring-offset-2 focus:ring-offset-editor-dialog",
handle.className,
handle.cursorClassName,
activeHandle === handle.handle && "scale-110",
)}
onPointerDown={(event) => handlePointerDown(event, handle.handle)}
onFocus={() => setActiveHandle(handle.handle)}
onBlur={() => setActiveHandle(null)}
onKeyDown={(event) => handleKeyboardAdjust(event, handle.handle)}
/>
))}
</div>
</div>
);
}
@@ -57,8 +57,8 @@ import {
DEFAULT_ZOOM_IN_EASING,
DEFAULT_ZOOM_IN_OVERLAP_MS,
DEFAULT_ZOOM_MOTION_BLUR,
DEFAULT_ZOOM_SMOOTHNESS,
DEFAULT_ZOOM_OUT_EASING,
DEFAULT_ZOOM_SMOOTHNESS,
getDefaultCaptionFontFamily,
type Padding,
type SpeedRegion,
@@ -67,6 +67,7 @@ import {
type ZoomRegion,
type ZoomTransitionEasing,
} from "./types";
import { normalizeWebcamCropRegion } from "./webcamOverlay";
export const PROJECT_VERSION = 1;
@@ -871,6 +872,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
sourcePath: webcamSourcePath,
mirror:
typeof webcam.mirror === "boolean" ? webcam.mirror : DEFAULT_WEBCAM_OVERLAY.mirror,
cropRegion: normalizeWebcamCropRegion(webcam.cropRegion),
positionPreset:
webcam.positionPreset === "top-left" ||
webcam.positionPreset === "top-center" ||
+2
View File
@@ -83,6 +83,7 @@ export interface WebcamOverlaySettings {
sourcePath: string | null;
timeOffsetMs: number;
mirror: boolean;
cropRegion: CropRegion;
corner: WebcamCorner;
positionPreset: WebcamPositionPreset;
positionX: number;
@@ -125,6 +126,7 @@ export const DEFAULT_WEBCAM_OVERLAY: WebcamOverlaySettings = {
sourcePath: null,
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
mirror: true,
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
corner: "bottom-right",
positionPreset: DEFAULT_WEBCAM_POSITION_PRESET,
positionX: DEFAULT_WEBCAM_POSITION_X,
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
getWebcamCropSourceRect,
isWebcamCropRegionDefault,
normalizeWebcamCropRegion,
} from "./webcamOverlay";
describe("normalizeWebcamCropRegion", () => {
it("defaults to the full webcam frame", () => {
expect(normalizeWebcamCropRegion()).toEqual({ x: 0, y: 0, width: 1, height: 1 });
expect(isWebcamCropRegionDefault()).toBe(true);
});
it("clamps crop dimensions inside the source frame", () => {
const crop = normalizeWebcamCropRegion({ x: 0.8, y: -1, width: 0.5, height: 2 });
expect(crop.x).toBe(0.8);
expect(crop.y).toBe(0);
expect(crop.width).toBeCloseTo(0.2);
expect(crop.height).toBe(1);
});
});
describe("getWebcamCropSourceRect", () => {
it("converts normalized crop settings to source pixels", () => {
expect(
getWebcamCropSourceRect({ x: 0.25, y: 0.1, width: 0.5, height: 0.75 }, 1920, 1080),
).toEqual({
sx: 480,
sy: 108,
sw: 960,
sh: 810,
});
});
});
+42 -1
View File
@@ -1,4 +1,4 @@
import type { WebcamCorner, WebcamPositionPreset } from "./types";
import type { CropRegion, WebcamCorner, WebcamPositionPreset } from "./types";
const MIN_WEBCAM_OVERLAY_SIZE_PX = 56;
@@ -110,3 +110,44 @@ export function getWebcamOverlayPosition({
y: safeMargin + availableHeight * presetPosition.y,
};
}
export function normalizeWebcamCropRegion(cropRegion?: Partial<CropRegion> | null): CropRegion {
const candidate = cropRegion ?? {};
const rawX = Number.isFinite(candidate.x) ? (candidate.x as number) : 0;
const rawY = Number.isFinite(candidate.y) ? (candidate.y as number) : 0;
const x = clamp(rawX, 0, 0.99);
const y = clamp(rawY, 0, 0.99);
const width = clamp(
Number.isFinite(candidate.width) ? (candidate.width as number) : 1,
0.01,
1 - x,
);
const height = clamp(
Number.isFinite(candidate.height) ? (candidate.height as number) : 1,
0.01,
1 - y,
);
return { x, y, width, height };
}
export function isWebcamCropRegionDefault(cropRegion?: Partial<CropRegion> | null): boolean {
const crop = normalizeWebcamCropRegion(cropRegion);
return crop.x <= 0 && crop.y <= 0 && crop.width >= 1 && crop.height >= 1;
}
export function getWebcamCropSourceRect(
cropRegion: Partial<CropRegion> | null | undefined,
sourceWidth: number,
sourceHeight: number,
): { sx: number; sy: number; sw: number; sh: number } {
const crop = normalizeWebcamCropRegion(cropRegion);
const safeWidth = Math.max(1, sourceWidth);
const safeHeight = Math.max(1, sourceHeight);
const sx = clamp(crop.x * safeWidth, 0, safeWidth - 1);
const sy = clamp(crop.y * safeHeight, 0, safeHeight - 1);
const sw = clamp(crop.width * safeWidth, 1, safeWidth - sx);
const sh = clamp(crop.height * safeHeight, 1, safeHeight - sy);
return { sx, sy, sw, sh };
}
+2
View File
@@ -107,7 +107,9 @@
"webcamFootageAdded": "Webcam footage linked",
"webcamFootageRemoved": "Webcam footage removed",
"webcamSize": "Webcam Size",
"webcamCrop": "Webcam Crop",
"webcamReactToZoom": "Webcam Reacts To Zoom",
"webcamMirror": "Mirror webcam",
"webcamRoundness": "Webcam Roundness",
"webcamShadow": "Webcam Shadow",
"shadow": "Shadow",
+2
View File
@@ -85,7 +85,9 @@
"webcamFootageAdded": "Metraje de cámara vinculado",
"webcamFootageRemoved": "Metraje de cámara eliminado",
"webcamSize": "Tamaño de cámara",
"webcamCrop": "Recorte de cámara",
"webcamReactToZoom": "La cámara reacciona al zoom",
"webcamMirror": "Reflejar cámara",
"webcamRoundness": "Redondez de cámara",
"webcamShadow": "Sombra de cámara",
"shadow": "Sombra",
+2
View File
@@ -85,7 +85,9 @@
"webcamFootageAdded": "Vidéo de webcam liée",
"webcamFootageRemoved": "Vidéo de webcam supprimée",
"webcamSize": "Taille de la webcam",
"webcamCrop": "Recadrage de la webcam",
"webcamReactToZoom": "La webcam réagit au zoom",
"webcamMirror": "Miroir webcam",
"webcamRoundness": "Arrondi de la webcam",
"webcamShadow": "Ombre de la webcam",
"shadow": "Ombre",
+2
View File
@@ -85,7 +85,9 @@
"webcamFootageAdded": "웹캠 영상을 연결했습니다",
"webcamFootageRemoved": "웹캠 영상을 제거했습니다",
"webcamSize": "웹캠 크기",
"webcamCrop": "웹캠 자르기",
"webcamReactToZoom": "확대 시 웹캠 반응",
"webcamMirror": "웹캠 미러링",
"webcamRoundness": "웹캠 둥글기",
"webcamShadow": "웹캠 그림자",
"shadow": "그림자",
+2
View File
@@ -85,7 +85,9 @@
"webcamFootageAdded": "Webcambeelden gekoppeld",
"webcamFootageRemoved": "Webcambeelden verwijderd",
"webcamSize": "Webcamgrootte",
"webcamCrop": "Webcam bijsnijden",
"webcamReactToZoom": "Webcam reageert op zoom",
"webcamMirror": "Webcam spiegelen",
"webcamRoundness": "Webcam-afronding",
"webcamShadow": "Webcamschaduw",
"shadow": "Schaduw",
+2
View File
@@ -85,7 +85,9 @@
"webcamFootageAdded": "Filmagem da webcam vinculada",
"webcamFootageRemoved": "Filmagem da webcam removida",
"webcamSize": "Tamanho da webcam",
"webcamCrop": "Corte da webcam",
"webcamReactToZoom": "Webcam reage ao zoom",
"webcamMirror": "Espelhar webcam",
"webcamRoundness": "Arredondamento da webcam",
"webcamShadow": "Sombra da webcam",
"shadow": "Sombra",
+2
View File
@@ -102,7 +102,9 @@
"webcamFootageAdded": "已关联摄像头素材",
"webcamFootageRemoved": "已移除摄像头素材",
"webcamSize": "摄像头大小",
"webcamCrop": "摄像头裁剪",
"webcamReactToZoom": "摄像头随缩放变化",
"webcamMirror": "镜像摄像头",
"webcamRoundness": "摄像头圆角",
"webcamShadow": "摄像头阴影",
"shadow": "阴影",
+3 -1
View File
@@ -31,7 +31,7 @@
"cursorStyleOptions": {
"macos": "macOS",
"tahoe": "Tahoe",
"tahoe-inverted": "Tahoe Inverted",
"tahoe-inverted": "Tahoe 反相",
"dot": "圓點",
"figma": "極簡",
"lavender": "Lavender",
@@ -85,7 +85,9 @@
"webcamFootageAdded": "已連結網路攝影機素材",
"webcamFootageRemoved": "已移除網路攝影機素材",
"webcamSize": "網路攝影機大小",
"webcamCrop": "網路攝影機裁剪",
"webcamReactToZoom": "網路攝影機跟隨縮放",
"webcamMirror": "鏡像網路攝影機",
"webcamRoundness": "網路攝影機圓角",
"webcamShadow": "網路攝影機陰影",
"shadow": "陰影",
+31 -5
View File
@@ -49,6 +49,7 @@ import {
type MotionBlurState,
} from "@/components/video-editor/videoPlayback/zoomTransform";
import {
getWebcamCropSourceRect,
getWebcamOverlayPosition,
getWebcamOverlaySizePx,
} from "@/components/video-editor/webcamOverlay";
@@ -2269,9 +2270,14 @@ export class FrameRenderer {
: "videoHeight" in webcamFrameSource
? webcamFrameSource.videoHeight
: webcamFrameSource.height) || size;
const coverScale = Math.max(size / sourceWidth, size / sourceHeight);
const drawWidth = sourceWidth * coverScale;
const drawHeight = sourceHeight * coverScale;
const { sx, sy, sw, sh } = getWebcamCropSourceRect(
webcam.cropRegion,
sourceWidth,
sourceHeight,
);
const coverScale = Math.max(size / sw, size / sh);
const drawWidth = sw * coverScale;
const drawHeight = sh * coverScale;
const drawX = (size - drawWidth) / 2;
const drawY = (size - drawHeight) / 2;
@@ -2282,10 +2288,30 @@ export class FrameRenderer {
bubbleCtx.save();
bubbleCtx.translate(size, 0);
bubbleCtx.scale(-1, 1);
bubbleCtx.drawImage(webcamFrameSource, drawX, drawY, drawWidth, drawHeight);
bubbleCtx.drawImage(
webcamFrameSource,
sx,
sy,
sw,
sh,
drawX,
drawY,
drawWidth,
drawHeight,
);
bubbleCtx.restore();
} else {
bubbleCtx.drawImage(webcamFrameSource, drawX, drawY, drawWidth, drawHeight);
bubbleCtx.drawImage(
webcamFrameSource,
sx,
sy,
sw,
sh,
drawX,
drawY,
drawWidth,
drawHeight,
);
}
bubbleCtx.restore();
@@ -246,3 +246,27 @@ describe("ModernFrameRenderer blur export path", () => {
expect(renderer.backgroundVideoElement).toBeNull();
});
});
describe("ModernFrameRenderer webcam frame cache", () => {
it("keeps the refresh throttle for default crop regions", () => {
const renderer = createRenderer() as any;
renderer.config.webcam.cropRegion = { x: 0, y: 0, width: 1, height: 1 };
renderer.webcamFrameCacheCanvas = { width: 1280, height: 720 };
renderer.lastWebcamCacheRefreshTime = 10;
renderer.currentVideoTime = 10.1;
expect(renderer.shouldRefreshWebcamFrameCache(1280, 720)).toBe(false);
});
it("bypasses the refresh throttle for cropped webcam regions", () => {
const renderer = createRenderer() as any;
renderer.config.webcam.cropRegion = { x: 0.25, y: 0, width: 0.5, height: 1 };
renderer.webcamFrameCacheCanvas = { width: 640, height: 720 };
renderer.lastWebcamCacheRefreshTime = 10;
renderer.currentVideoTime = 10.1;
expect(renderer.shouldRefreshWebcamFrameCache(1280, 720)).toBe(true);
});
});
+24 -7
View File
@@ -55,8 +55,10 @@ import {
type MotionBlurState,
} from "@/components/video-editor/videoPlayback/zoomTransform";
import {
getWebcamCropSourceRect,
getWebcamOverlayPosition,
getWebcamOverlaySizePx,
isWebcamCropRegionDefault,
} from "@/components/video-editor/webcamOverlay";
import { getAssetPath, getExportableVideoUrl, getRenderableAssetUrl } from "@/lib/assetPath";
import { extensionHost } from "@/lib/extensions";
@@ -89,10 +91,7 @@ import {
VIDEO_SHADOW_LAYER_PROFILES,
WEBCAM_SHADOW_LAYER_PROFILES,
} from "./shadowProfile";
import {
buildTemporalSamplePlanUs,
getTemporalMotionBlurConfig,
} from "./temporalMotionBlur";
import { buildTemporalSamplePlanUs, getTemporalMotionBlurConfig } from "./temporalMotionBlur";
import type { ExportRenderBackend } from "./types";
interface FrameRenderConfig {
@@ -2016,8 +2015,10 @@ export class FrameRenderer {
}
private shouldRefreshWebcamFrameCache(width: number, height: number): boolean {
const targetWidth = Math.max(1, Math.ceil(width));
const targetHeight = Math.max(1, Math.ceil(height));
const cropRegion = this.config.webcam?.cropRegion;
const sourceRect = getWebcamCropSourceRect(cropRegion, width, height);
const targetWidth = Math.max(1, Math.ceil(sourceRect.sw));
const targetHeight = Math.max(1, Math.ceil(sourceRect.sh));
if (
!this.webcamFrameCacheCanvas ||
@@ -2027,6 +2028,10 @@ export class FrameRenderer {
return true;
}
if (!isWebcamCropRegionDefault(cropRegion)) {
return true;
}
if (this.lastWebcamCacheRefreshTime === null) {
return true;
}
@@ -2062,7 +2067,8 @@ export class FrameRenderer {
width: number,
height: number,
): boolean {
if (!this.ensureWebcamFrameCache(width, height)) {
const sourceRect = getWebcamCropSourceRect(this.config.webcam?.cropRegion, width, height);
if (!this.ensureWebcamFrameCache(sourceRect.sw, sourceRect.sh)) {
return false;
}
@@ -2078,6 +2084,10 @@ export class FrameRenderer {
);
this.webcamFrameCacheCtx.drawImage(
source,
sourceRect.sx,
sourceRect.sy,
sourceRect.sw,
sourceRect.sh,
0,
0,
this.webcamFrameCacheCanvas.width,
@@ -2114,6 +2124,13 @@ export class FrameRenderer {
if (this.shouldRefreshWebcamFrameCache(liveSourceWidth, liveSourceHeight)) {
this.refreshWebcamFrameCache(liveSource, liveSourceWidth, liveSourceHeight);
}
if (!isWebcamCropRegionDefault(this.config.webcam?.cropRegion)) {
const cachedSource = this.getCachedWebcamRenderSource();
if (cachedSource) {
this.setWebcamRenderMode("live");
return cachedSource;
}
}
this.setWebcamRenderMode("live");
return {
source: liveSource,