Merge branch 'main' of github.com:siddharthvaddem/openscreen into feature/color-wheel
This commit is contained in:
@@ -52,10 +52,74 @@ import type {
|
||||
PlaybackSpeed,
|
||||
WebcamLayoutPreset,
|
||||
WebcamMaskShape,
|
||||
WebcamSizePreset,
|
||||
ZoomDepth,
|
||||
ZoomFocusMode,
|
||||
} from "./types";
|
||||
import { SPEED_OPTIONS } from "./types";
|
||||
import { DEFAULT_WEBCAM_SIZE_PRESET, MAX_PLAYBACK_SPEED, SPEED_OPTIONS } from "./types";
|
||||
|
||||
function CustomSpeedInput({
|
||||
value,
|
||||
onChange,
|
||||
onError,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (val: number) => void;
|
||||
onError: () => void;
|
||||
}) {
|
||||
const isPreset = SPEED_OPTIONS.some((o) => o.speed === value);
|
||||
const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value)));
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const prevValue = useRef(value);
|
||||
if (!isFocused && prevValue.current !== value) {
|
||||
prevValue.current = value;
|
||||
setDraft(isPreset ? "" : String(Math.round(value)));
|
||||
}
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const digits = e.target.value.replace(/\D/g, "");
|
||||
if (digits === "") {
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
const num = Number(digits);
|
||||
if (num > MAX_PLAYBACK_SPEED) {
|
||||
onError();
|
||||
return;
|
||||
}
|
||||
setDraft(digits);
|
||||
if (num >= 1) onChange(num);
|
||||
},
|
||||
[onChange, onError],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
if (!draft || Number(draft) < 1) {
|
||||
setDraft(isPreset ? "" : String(Math.round(value)));
|
||||
}
|
||||
}, [draft, isPreset, value]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
placeholder="--"
|
||||
value={draft}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
||||
className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-[#d97706] text-center focus:outline-none focus:border-[#d97706]/40"
|
||||
/>
|
||||
<span className="text-[11px] font-semibold text-slate-500">×</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const WALLPAPER_COUNT = 18;
|
||||
const WALLPAPER_RELATIVE = Array.from(
|
||||
@@ -132,7 +196,11 @@ interface SettingsPanelProps {
|
||||
onGifSizePresetChange?: (preset: GifSizePreset) => void;
|
||||
gifOutputDimensions?: { width: number; height: number };
|
||||
onExport?: () => void;
|
||||
unsavedExport?: { arrayBuffer: ArrayBuffer; fileName: string; format: string } | null;
|
||||
unsavedExport?: {
|
||||
arrayBuffer: ArrayBuffer;
|
||||
fileName: string;
|
||||
format: string;
|
||||
} | null;
|
||||
onSaveUnsavedExport?: () => void;
|
||||
selectedAnnotationId?: string | null;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
@@ -150,6 +218,9 @@ interface SettingsPanelProps {
|
||||
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
|
||||
webcamMaskShape?: import("./types").WebcamMaskShape;
|
||||
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
|
||||
onWebcamSizePresetCommit?: () => void;
|
||||
}
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -223,6 +294,9 @@ export function SettingsPanel({
|
||||
onWebcamLayoutPresetChange,
|
||||
webcamMaskShape = "rectangle",
|
||||
onWebcamMaskShapeChange,
|
||||
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
onWebcamSizePresetChange,
|
||||
onWebcamSizePresetCommit,
|
||||
}: SettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
|
||||
@@ -584,7 +658,7 @@ export function SettingsPanel({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{SPEED_OPTIONS.map((option) => {
|
||||
const isActive = selectedSpeedValue === option.speed;
|
||||
return (
|
||||
@@ -609,6 +683,29 @@ export function SettingsPanel({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn("text-[11px]", selectedSpeedId ? "text-slate-500" : "text-slate-600")}
|
||||
>
|
||||
{t("speed.customPlaybackSpeed")}
|
||||
</span>
|
||||
{selectedSpeedId ? (
|
||||
<CustomSpeedInput
|
||||
value={selectedSpeedValue ?? 1}
|
||||
onChange={(val) => onSpeedChange?.(val)}
|
||||
onError={() => toast.error(t("speed.maxSpeedError"))}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 opacity-40">
|
||||
<div className="w-12 bg-white/5 border border-white/10 rounded-md px-1 py-0.5 text-[11px] font-semibold text-slate-600 text-center">
|
||||
--
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-slate-600">×</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!selectedSpeedId && (
|
||||
<p className="text-[10px] text-slate-500 mt-2 text-center">{t("speed.selectRegion")}</p>
|
||||
)}
|
||||
@@ -751,6 +848,27 @@ export function SettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{webcamLayoutPreset === "picture-in-picture" && (
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/5 mt-2">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("layout.webcamSize")}
|
||||
</div>
|
||||
<div className="text-[10px] font-medium text-slate-400">
|
||||
{webcamSizePreset}%
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
value={[webcamSizePreset]}
|
||||
onValueChange={(values) => onWebcamSizePresetChange?.(values[0])}
|
||||
onValueCommit={() => onWebcamSizePresetCommit?.()}
|
||||
min={10}
|
||||
max={50}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
@@ -1014,7 +1132,9 @@ export function SettingsPanel({
|
||||
: "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5",
|
||||
)}
|
||||
style={{ background: g }}
|
||||
aria-label={t("background.gradientLabel", { index: idx + 1 })}
|
||||
aria-label={t("background.gradientLabel", {
|
||||
index: idx + 1,
|
||||
})}
|
||||
onClick={() => {
|
||||
setGradient(g);
|
||||
onWallpaperChange(g);
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import type { Span } from "dnd-timeline";
|
||||
import { FolderOpen, Languages, Save } from "lucide-react";
|
||||
import { FolderOpen, Languages, Save, Video } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useI18n, useScopedT } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
|
||||
@@ -90,6 +98,7 @@ export default function VideoEditor() {
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
} = editorState;
|
||||
|
||||
@@ -117,6 +126,7 @@ export default function VideoEditor() {
|
||||
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false);
|
||||
const [exportQuality, setExportQuality] = useState<ExportQuality>("good");
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormat>("mp4");
|
||||
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(15);
|
||||
@@ -206,6 +216,7 @@ export default function VideoEditor() {
|
||||
aspectRatio: normalizedEditor.aspectRatio,
|
||||
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
|
||||
webcamMaskShape: normalizedEditor.webcamMaskShape,
|
||||
webcamSizePreset: normalizedEditor.webcamSizePreset,
|
||||
webcamPosition: normalizedEditor.webcamPosition,
|
||||
});
|
||||
setExportQuality(normalizedEditor.exportQuality);
|
||||
@@ -296,6 +307,7 @@ export default function VideoEditor() {
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
@@ -416,6 +428,7 @@ export default function VideoEditor() {
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
@@ -471,6 +484,7 @@ export default function VideoEditor() {
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
@@ -501,6 +515,16 @@ export default function VideoEditor() {
|
||||
await saveProject(true);
|
||||
}, [saveProject]);
|
||||
|
||||
const handleNewRecordingConfirm = useCallback(async () => {
|
||||
const result = await window.electronAPI.startNewRecording();
|
||||
if (result.success) {
|
||||
setShowNewRecordingDialog(false);
|
||||
} else {
|
||||
console.error("Failed to start new recording:", result.error);
|
||||
setError("Failed to start new recording: " + (result.error || "Unknown error"));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLoadProject = useCallback(async () => {
|
||||
const result = await window.electronAPI.loadProjectFile();
|
||||
|
||||
@@ -678,7 +702,11 @@ export default function VideoEditor() {
|
||||
pushState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
|
||||
? {
|
||||
...region,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
@@ -691,7 +719,11 @@ export default function VideoEditor() {
|
||||
pushState((prev) => ({
|
||||
trimRegions: prev.trimRegions.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
|
||||
? {
|
||||
...region,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
@@ -717,7 +749,11 @@ export default function VideoEditor() {
|
||||
pushState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.map((region) =>
|
||||
region.id === selectedZoomId
|
||||
? { ...region, depth, focus: clampFocusToDepth(region.focus, depth) }
|
||||
? {
|
||||
...region,
|
||||
depth,
|
||||
focus: clampFocusToDepth(region.focus, depth),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
@@ -739,7 +775,9 @@ export default function VideoEditor() {
|
||||
|
||||
const handleZoomDelete = useCallback(
|
||||
(id: string) => {
|
||||
pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) }));
|
||||
pushState((prev) => ({
|
||||
zoomRegions: prev.zoomRegions.filter((r) => r.id !== id),
|
||||
}));
|
||||
if (selectedZoomId === id) {
|
||||
setSelectedZoomId(null);
|
||||
}
|
||||
@@ -749,7 +787,9 @@ export default function VideoEditor() {
|
||||
|
||||
const handleTrimDelete = useCallback(
|
||||
(id: string) => {
|
||||
pushState((prev) => ({ trimRegions: prev.trimRegions.filter((r) => r.id !== id) }));
|
||||
pushState((prev) => ({
|
||||
trimRegions: prev.trimRegions.filter((r) => r.id !== id),
|
||||
}));
|
||||
if (selectedTrimId === id) {
|
||||
setSelectedTrimId(null);
|
||||
}
|
||||
@@ -775,7 +815,9 @@ export default function VideoEditor() {
|
||||
endMs: Math.round(span.end),
|
||||
speed: DEFAULT_PLAYBACK_SPEED,
|
||||
};
|
||||
pushState((prev) => ({ speedRegions: [...prev.speedRegions, newRegion] }));
|
||||
pushState((prev) => ({
|
||||
speedRegions: [...prev.speedRegions, newRegion],
|
||||
}));
|
||||
setSelectedSpeedId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
@@ -840,7 +882,9 @@ export default function VideoEditor() {
|
||||
style: { ...DEFAULT_ANNOTATION_STYLE },
|
||||
zIndex,
|
||||
};
|
||||
pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, newRegion] }));
|
||||
pushState((prev) => ({
|
||||
annotationRegions: [...prev.annotationRegions, newRegion],
|
||||
}));
|
||||
setSelectedAnnotationId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
@@ -853,7 +897,11 @@ export default function VideoEditor() {
|
||||
pushState((prev) => ({
|
||||
annotationRegions: prev.annotationRegions.map((region) =>
|
||||
region.id === id
|
||||
? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
|
||||
? {
|
||||
...region,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
}));
|
||||
@@ -1174,6 +1222,7 @@ export default function VideoEditor() {
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
@@ -1307,6 +1356,7 @@ export default function VideoEditor() {
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
@@ -1377,6 +1427,7 @@ export default function VideoEditor() {
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
handleExportSaved,
|
||||
@@ -1482,6 +1533,34 @@ export default function VideoEditor() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-[#09090b] text-slate-200 overflow-hidden selection:bg-[#34B27B]/30">
|
||||
<Dialog open={showNewRecordingDialog} onOpenChange={setShowNewRecordingDialog}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[425px]"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("newRecording.title")}</DialogTitle>
|
||||
<DialogDescription>{t("newRecording.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewRecordingDialog(false)}
|
||||
className="px-4 py-2 rounded-md bg-white/10 text-white hover:bg-white/20 text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("newRecording.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewRecordingConfirm}
|
||||
className="px-4 py-2 rounded-md bg-[#34B27B] text-white hover:bg-[#34B27B]/90 text-sm font-medium transition-colors"
|
||||
>
|
||||
{t("newRecording.confirm")}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div
|
||||
className="h-10 flex-shrink-0 bg-[#09090b]/80 backdrop-blur-md border-b border-white/5 flex items-center justify-between px-6 z-50"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
@@ -1507,6 +1586,14 @@ export default function VideoEditor() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewRecordingDialog(true)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-white/50 hover:text-white/90 hover:bg-white/10 transition-all duration-150 text-[11px] font-medium"
|
||||
>
|
||||
<Video size={14} />
|
||||
{t("newRecording.title")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadProject}
|
||||
@@ -1563,6 +1650,7 @@ export default function VideoEditor() {
|
||||
webcamVideoPath={webcamVideoPath || undefined}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
webcamPosition={webcamPosition}
|
||||
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
|
||||
onWebcamPositionDragEnd={commitState}
|
||||
@@ -1713,6 +1801,9 @@ export default function VideoEditor() {
|
||||
}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
|
||||
webcamSizePreset={webcamSizePreset}
|
||||
onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })}
|
||||
onWebcamSizePresetCommit={commitState}
|
||||
videoElement={videoPlaybackRef.current?.video || null}
|
||||
exportQuality={exportQuality}
|
||||
onExportQualityChange={setExportQuality}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type Size,
|
||||
type StyledRenderRect,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamSizePreset,
|
||||
} from "@/lib/compositeLayout";
|
||||
import { getCssClipPath } from "@/lib/webcamMaskShapes";
|
||||
import {
|
||||
@@ -69,6 +70,7 @@ interface VideoPlaybackProps {
|
||||
webcamVideoPath?: string;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
webcamMaskShape?: import("./types").WebcamMaskShape;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
|
||||
onWebcamPositionDragEnd?: () => void;
|
||||
@@ -119,6 +121,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
webcamVideoPath,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
onWebcamPositionChange,
|
||||
onWebcamPositionDragEnd,
|
||||
@@ -195,7 +198,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const isPlayingRef = useRef(isPlaying);
|
||||
const isSeekingRef = useRef(false);
|
||||
const allowPlaybackRef = useRef(false);
|
||||
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
|
||||
const lockedVideoDimensionsRef = useRef<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const layoutVideoContentRef = useRef<(() => void) | null>(null);
|
||||
const trimRegionsRef = useRef<TrimRegion[]>([]);
|
||||
const speedRegionsRef = useRef<SpeedRegion[]>([]);
|
||||
@@ -283,6 +289,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
padding,
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
});
|
||||
@@ -314,6 +321,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
padding,
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
]);
|
||||
@@ -648,7 +656,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
app.ticker.maxFPS = 60;
|
||||
|
||||
if (!mounted) {
|
||||
app.destroy(true, { children: true, texture: true, textureSource: true });
|
||||
app.destroy(true, {
|
||||
children: true,
|
||||
texture: true,
|
||||
textureSource: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -672,7 +684,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
mounted = false;
|
||||
setPixiReady(false);
|
||||
if (app && app.renderer) {
|
||||
app.destroy(true, { children: true, texture: true, textureSource: true });
|
||||
app.destroy(true, {
|
||||
children: true,
|
||||
texture: true,
|
||||
textureSource: true,
|
||||
});
|
||||
}
|
||||
appRef.current = null;
|
||||
cameraContainerRef.current = null;
|
||||
@@ -853,12 +869,19 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const ss = stageSizeRef.current;
|
||||
const viewportRatio =
|
||||
bm.width > 0 && bm.height > 0
|
||||
? { widthRatio: ss.width / bm.width, heightRatio: ss.height / bm.height }
|
||||
? {
|
||||
widthRatio: ss.width / bm.width,
|
||||
heightRatio: ss.height / bm.height,
|
||||
}
|
||||
: undefined;
|
||||
const { region, strength, blendedScale, transition } = findDominantRegion(
|
||||
zoomRegionsRef.current,
|
||||
currentTimeRef.current,
|
||||
{ connectZooms: true, cursorTelemetry: cursorTelemetryRef.current, viewportRatio },
|
||||
{
|
||||
connectZooms: true,
|
||||
cursorTelemetry: cursorTelemetryRef.current,
|
||||
viewportRatio,
|
||||
},
|
||||
);
|
||||
|
||||
const defaultFocus = DEFAULT_FOCUS;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type CropRegion,
|
||||
clampPlaybackSpeed,
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
@@ -14,12 +15,16 @@ import {
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
MIN_PLAYBACK_SPEED,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamMaskShape,
|
||||
type WebcamPosition,
|
||||
type WebcamSizePreset,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
|
||||
@@ -47,6 +52,7 @@ export interface ProjectEditorState {
|
||||
aspectRatio: AspectRatio;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
webcamMaskShape: WebcamMaskShape;
|
||||
webcamSizePreset: WebcamSizePreset;
|
||||
webcamPosition: WebcamPosition | null;
|
||||
exportQuality: ExportQuality;
|
||||
exportFormat: ExportFormat;
|
||||
@@ -223,14 +229,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
|
||||
const speed =
|
||||
region.speed === 0.25 ||
|
||||
region.speed === 0.5 ||
|
||||
region.speed === 0.75 ||
|
||||
region.speed === 1.25 ||
|
||||
region.speed === 1.5 ||
|
||||
region.speed === 1.75 ||
|
||||
region.speed === 2
|
||||
? region.speed
|
||||
isFiniteNumber(region.speed) &&
|
||||
region.speed >= MIN_PLAYBACK_SPEED &&
|
||||
region.speed <= MAX_PLAYBACK_SPEED
|
||||
? clampPlaybackSpeed(region.speed)
|
||||
: DEFAULT_PLAYBACK_SPEED;
|
||||
|
||||
return {
|
||||
@@ -363,6 +365,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
editor.webcamMaskShape === "rounded"
|
||||
? editor.webcamMaskShape
|
||||
: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
webcamSizePreset:
|
||||
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
|
||||
? Math.max(10, Math.min(50, editor.webcamSizePreset))
|
||||
: DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
webcamPosition:
|
||||
editor.webcamPosition &&
|
||||
typeof editor.webcamPosition === "object" &&
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
|
||||
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
export type ZoomFocusMode = "manual" | "auto";
|
||||
export type { WebcamLayoutPreset };
|
||||
/** Webcam size as a percentage of the canvas reference dimension (10–50). */
|
||||
export type WebcamSizePreset = number;
|
||||
|
||||
export const DEFAULT_WEBCAM_SIZE_PRESET: WebcamSizePreset = 25;
|
||||
|
||||
export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
|
||||
|
||||
@@ -138,7 +142,16 @@ export const DEFAULT_CROP_REGION: CropRegion = {
|
||||
height: 1,
|
||||
};
|
||||
|
||||
export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;
|
||||
export type PlaybackSpeed = number;
|
||||
|
||||
export const MIN_PLAYBACK_SPEED = 0.1;
|
||||
// Anything above 16x causes the playhead to stall during preview
|
||||
// due to the video decoder not being able to keep up.
|
||||
export const MAX_PLAYBACK_SPEED = 16;
|
||||
|
||||
export function clampPlaybackSpeed(speed: number): PlaybackSpeed {
|
||||
return Math.round(Math.min(MAX_PLAYBACK_SPEED, Math.max(MIN_PLAYBACK_SPEED, speed)) * 100) / 100;
|
||||
}
|
||||
|
||||
export interface SpeedRegion {
|
||||
id: string;
|
||||
@@ -155,6 +168,9 @@ export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [
|
||||
{ speed: 1.5, label: "1.5×" },
|
||||
{ speed: 1.75, label: "1.75×" },
|
||||
{ speed: 2, label: "2×" },
|
||||
{ speed: 3, label: "3×" },
|
||||
{ speed: 4, label: "4×" },
|
||||
{ speed: 5, label: "5×" },
|
||||
];
|
||||
|
||||
export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type Size,
|
||||
type StyledRenderRect,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamSizePreset,
|
||||
} from "@/lib/compositeLayout";
|
||||
import type { CropRegion, WebcamMaskShape } from "../types";
|
||||
|
||||
@@ -20,6 +21,7 @@ interface LayoutParams {
|
||||
padding?: number;
|
||||
webcamDimensions?: Size | null;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
webcamMaskShape?: WebcamMaskShape;
|
||||
}
|
||||
@@ -47,6 +49,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
padding = 0,
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
} = params;
|
||||
@@ -95,6 +98,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||
webcamSize: webcamDimensions,
|
||||
layoutPreset: webcamLayoutPreset,
|
||||
webcamSizePreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user