mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 07:45:34 +00:00
Merge pull request #394 from wizardAEI/codex/feature/camera-crop
[codex] Add webcam crop and mirror controls
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -85,7 +85,9 @@
|
||||
"webcamFootageAdded": "웹캠 영상을 연결했습니다",
|
||||
"webcamFootageRemoved": "웹캠 영상을 제거했습니다",
|
||||
"webcamSize": "웹캠 크기",
|
||||
"webcamCrop": "웹캠 자르기",
|
||||
"webcamReactToZoom": "확대 시 웹캠 반응",
|
||||
"webcamMirror": "웹캠 미러링",
|
||||
"webcamRoundness": "웹캠 둥글기",
|
||||
"webcamShadow": "웹캠 그림자",
|
||||
"shadow": "그림자",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -102,7 +102,9 @@
|
||||
"webcamFootageAdded": "已关联摄像头素材",
|
||||
"webcamFootageRemoved": "已移除摄像头素材",
|
||||
"webcamSize": "摄像头大小",
|
||||
"webcamCrop": "摄像头裁剪",
|
||||
"webcamReactToZoom": "摄像头随缩放变化",
|
||||
"webcamMirror": "镜像摄像头",
|
||||
"webcamRoundness": "摄像头圆角",
|
||||
"webcamShadow": "摄像头阴影",
|
||||
"shadow": "阴影",
|
||||
|
||||
@@ -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": "陰影",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user