Add webcam recording overlay support
This commit is contained in:
@@ -4,7 +4,16 @@ import { BsRecordCircle } from "react-icons/bs";
|
||||
import { FaRegStopCircle } from "react-icons/fa";
|
||||
import { FaFolderOpen } from "react-icons/fa6";
|
||||
import { FiMinus, FiX } from "react-icons/fi";
|
||||
import { MdMic, MdMicOff, MdMonitor, MdVideoFile, MdVolumeOff, MdVolumeUp } from "react-icons/md";
|
||||
import {
|
||||
MdMic,
|
||||
MdMicOff,
|
||||
MdMonitor,
|
||||
MdVideocam,
|
||||
MdVideocamOff,
|
||||
MdVideoFile,
|
||||
MdVolumeOff,
|
||||
MdVolumeUp,
|
||||
} from "react-icons/md";
|
||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||
@@ -23,6 +32,8 @@ const ICON_CONFIG = {
|
||||
volumeOff: { icon: MdVolumeOff, size: ICON_SIZE },
|
||||
micOn: { icon: MdMic, size: ICON_SIZE },
|
||||
micOff: { icon: MdMicOff, size: ICON_SIZE },
|
||||
webcamOn: { icon: MdVideocam, size: ICON_SIZE },
|
||||
webcamOff: { icon: MdVideocamOff, size: ICON_SIZE },
|
||||
stop: { icon: FaRegStopCircle, size: ICON_SIZE },
|
||||
record: { icon: BsRecordCircle, size: ICON_SIZE },
|
||||
videoFile: { icon: MdVideoFile, size: ICON_SIZE },
|
||||
@@ -57,6 +68,8 @@ export function LaunchWindow() {
|
||||
setMicrophoneDeviceId,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
webcamEnabled,
|
||||
setWebcamEnabled,
|
||||
} = useScreenRecorder();
|
||||
const [recordingStart, setRecordingStart] = useState<number | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
@@ -233,6 +246,16 @@ export function LaunchWindow() {
|
||||
? getIcon("micOn", "text-green-400")
|
||||
: getIcon("micOff", "text-white/40")}
|
||||
</button>
|
||||
<button
|
||||
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
|
||||
onClick={() => !recording && setWebcamEnabled(!webcamEnabled)}
|
||||
disabled={recording}
|
||||
title={webcamEnabled ? "Disable webcam" : "Enable webcam"}
|
||||
>
|
||||
{webcamEnabled
|
||||
? getIcon("webcamOn", "text-green-400")
|
||||
: getIcon("webcamOff", "text-white/40")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Record/Stop group */}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type GifSizePreset,
|
||||
VideoExporter,
|
||||
} from "@/lib/exporter";
|
||||
import type { ProjectMedia } from "@/lib/recordingSession";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import { getAspectRatioValue, getNativeAspectRatioValue } from "@/utils/aspectRatioUtils";
|
||||
import { ExportDialog } from "./ExportDialog";
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
deriveNextId,
|
||||
fromFileUrl,
|
||||
normalizeProjectEditor,
|
||||
resolveProjectMedia,
|
||||
toFileUrl,
|
||||
validateProjectData,
|
||||
} from "./projectPersistence";
|
||||
@@ -79,6 +81,8 @@ export default function VideoEditor() {
|
||||
// ── Non-undoable state
|
||||
const [videoPath, setVideoPath] = useState<string | null>(null);
|
||||
const [videoSourcePath, setVideoSourcePath] = useState<string | null>(null);
|
||||
const [webcamVideoPath, setWebcamVideoPath] = useState<string | null>(null);
|
||||
const [webcamVideoSourcePath, setWebcamVideoSourcePath] = useState<string | null>(null);
|
||||
const [currentProjectPath, setCurrentProjectPath] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -111,6 +115,19 @@ export default function VideoEditor() {
|
||||
const nextAnnotationZIndexRef = useRef(1);
|
||||
const exporterRef = useRef<VideoExporter | null>(null);
|
||||
|
||||
const currentProjectMedia = useMemo<ProjectMedia | null>(() => {
|
||||
const screenVideoPath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
|
||||
if (!screenVideoPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const webcamSourcePath =
|
||||
webcamVideoSourcePath ?? (webcamVideoPath ? fromFileUrl(webcamVideoPath) : null);
|
||||
return webcamSourcePath
|
||||
? { screenVideoPath, webcamVideoPath: webcamSourcePath }
|
||||
: { screenVideoPath };
|
||||
}, [videoPath, videoSourcePath, webcamVideoPath, webcamVideoSourcePath]);
|
||||
|
||||
const applyLoadedProject = useCallback(
|
||||
async (candidate: unknown, path?: string | null) => {
|
||||
if (!validateProjectData(candidate)) {
|
||||
@@ -118,7 +135,12 @@ export default function VideoEditor() {
|
||||
}
|
||||
|
||||
const project = candidate;
|
||||
const sourcePath = fromFileUrl(project.videoPath);
|
||||
const media = resolveProjectMedia(project);
|
||||
if (!media) {
|
||||
return false;
|
||||
}
|
||||
const sourcePath = fromFileUrl(media.screenVideoPath);
|
||||
const webcamSourcePath = media.webcamVideoPath ? fromFileUrl(media.webcamVideoPath) : null;
|
||||
const normalizedEditor = normalizeProjectEditor(project.editor);
|
||||
|
||||
try {
|
||||
@@ -133,6 +155,8 @@ export default function VideoEditor() {
|
||||
setError(null);
|
||||
setVideoSourcePath(sourcePath);
|
||||
setVideoPath(toFileUrl(sourcePath));
|
||||
setWebcamVideoSourcePath(webcamSourcePath);
|
||||
setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null);
|
||||
setCurrentProjectPath(path ?? null);
|
||||
|
||||
pushState({
|
||||
@@ -182,19 +206,27 @@ export default function VideoEditor() {
|
||||
0,
|
||||
) + 1;
|
||||
|
||||
setLastSavedSnapshot(JSON.stringify(createProjectData(sourcePath, normalizedEditor)));
|
||||
setLastSavedSnapshot(
|
||||
JSON.stringify(
|
||||
createProjectData(
|
||||
webcamSourcePath
|
||||
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
|
||||
: { screenVideoPath: sourcePath },
|
||||
normalizedEditor,
|
||||
),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
[pushState],
|
||||
);
|
||||
|
||||
const currentProjectSnapshot = useMemo(() => {
|
||||
const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
|
||||
if (!sourcePath) {
|
||||
if (!currentProjectMedia) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify(
|
||||
createProjectData(sourcePath, {
|
||||
createProjectData(currentProjectMedia, {
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
@@ -215,8 +247,7 @@ export default function VideoEditor() {
|
||||
}),
|
||||
);
|
||||
}, [
|
||||
videoPath,
|
||||
videoSourcePath,
|
||||
currentProjectMedia,
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
@@ -257,11 +288,29 @@ export default function VideoEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
const currentSessionResult = await window.electronAPI.getCurrentRecordingSession();
|
||||
if (currentSessionResult.success && currentSessionResult.session) {
|
||||
const session = currentSessionResult.session;
|
||||
const sourcePath = fromFileUrl(session.screenVideoPath);
|
||||
const webcamSourcePath = session.webcamVideoPath
|
||||
? fromFileUrl(session.webcamVideoPath)
|
||||
: null;
|
||||
setVideoSourcePath(sourcePath);
|
||||
setVideoPath(toFileUrl(sourcePath));
|
||||
setWebcamVideoSourcePath(webcamSourcePath);
|
||||
setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null);
|
||||
setCurrentProjectPath(null);
|
||||
setLastSavedSnapshot(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.getCurrentVideoPath();
|
||||
if (result.success && result.path) {
|
||||
const sourcePath = fromFileUrl(result.path);
|
||||
setVideoSourcePath(sourcePath);
|
||||
setVideoPath(toFileUrl(sourcePath));
|
||||
setWebcamVideoSourcePath(null);
|
||||
setWebcamVideoPath(null);
|
||||
setCurrentProjectPath(null);
|
||||
setLastSavedSnapshot(null);
|
||||
} else {
|
||||
@@ -284,13 +333,12 @@ export default function VideoEditor() {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sourcePath = videoSourcePath ?? fromFileUrl(videoPath);
|
||||
if (!sourcePath) {
|
||||
if (!currentProjectMedia) {
|
||||
toast.error("Unable to determine source video path");
|
||||
return false;
|
||||
}
|
||||
|
||||
const projectData = createProjectData(sourcePath, {
|
||||
const projectData = createProjectData(currentProjectMedia, {
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
@@ -311,7 +359,7 @@ export default function VideoEditor() {
|
||||
});
|
||||
|
||||
const fileNameBase =
|
||||
sourcePath
|
||||
currentProjectMedia.screenVideoPath
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`;
|
||||
@@ -341,8 +389,7 @@ export default function VideoEditor() {
|
||||
return true;
|
||||
},
|
||||
[
|
||||
videoPath,
|
||||
videoSourcePath,
|
||||
currentProjectMedia,
|
||||
currentProjectPath,
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
@@ -361,6 +408,7 @@ export default function VideoEditor() {
|
||||
gifFrameRate,
|
||||
gifLoop,
|
||||
gifSizePreset,
|
||||
videoPath,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -420,7 +468,7 @@ export default function VideoEditor() {
|
||||
let mounted = true;
|
||||
|
||||
async function loadCursorTelemetry() {
|
||||
const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
|
||||
const sourcePath = currentProjectMedia?.screenVideoPath ?? null;
|
||||
|
||||
if (!sourcePath) {
|
||||
if (mounted) {
|
||||
@@ -447,7 +495,7 @@ export default function VideoEditor() {
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [videoPath, videoSourcePath]);
|
||||
}, [currentProjectMedia]);
|
||||
|
||||
function togglePlayPause() {
|
||||
const playback = videoPlaybackRef.current;
|
||||
@@ -921,6 +969,7 @@ export default function VideoEditor() {
|
||||
// GIF Export
|
||||
const gifExporter = new GifExporter({
|
||||
videoUrl: videoPath,
|
||||
webcamVideoUrl: webcamVideoPath || undefined,
|
||||
width: settings.gifConfig.width,
|
||||
height: settings.gifConfig.height,
|
||||
frameRate: settings.gifConfig.frameRate,
|
||||
@@ -1048,6 +1097,7 @@ export default function VideoEditor() {
|
||||
|
||||
const exporter = new VideoExporter({
|
||||
videoUrl: videoPath,
|
||||
webcamVideoUrl: webcamVideoPath || undefined,
|
||||
width: exportWidth,
|
||||
height: exportHeight,
|
||||
frameRate: 60,
|
||||
@@ -1115,6 +1165,7 @@ export default function VideoEditor() {
|
||||
},
|
||||
[
|
||||
videoPath,
|
||||
webcamVideoPath,
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
@@ -1251,10 +1302,11 @@ export default function VideoEditor() {
|
||||
}}
|
||||
>
|
||||
<VideoPlayback
|
||||
key={videoPath || "no-video"}
|
||||
key={`${videoPath || "no-video"}:${webcamVideoPath || "no-webcam"}`}
|
||||
aspectRatio={aspectRatio}
|
||||
ref={videoPlaybackRef}
|
||||
videoPath={videoPath || ""}
|
||||
webcamVideoPath={webcamVideoPath || undefined}
|
||||
onDurationChange={setDuration}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
currentTime={currentTime}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
import { computeWebcamOverlayLayout, type WebcamOverlayLayout } from "@/lib/webcamOverlay";
|
||||
import {
|
||||
type AspectRatio,
|
||||
formatAspectRatioForCSS,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
|
||||
interface VideoPlaybackProps {
|
||||
videoPath: string;
|
||||
webcamVideoPath?: string;
|
||||
onDurationChange: (duration: number) => void;
|
||||
onTimeUpdate: (time: number) => void;
|
||||
currentTime: number;
|
||||
@@ -98,6 +100,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
(
|
||||
{
|
||||
videoPath,
|
||||
webcamVideoPath,
|
||||
onDurationChange,
|
||||
onTimeUpdate,
|
||||
currentTime,
|
||||
@@ -129,7 +132,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
ref,
|
||||
) => {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const appRef = useRef<Application | null>(null);
|
||||
const videoSpriteRef = useRef<Sprite | null>(null);
|
||||
const videoContainerRef = useRef<Container | null>(null);
|
||||
@@ -139,6 +144,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const [videoReady, setVideoReady] = useState(false);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
|
||||
const [webcamLayout, setWebcamLayout] = useState<WebcamOverlayLayout | null>(null);
|
||||
const [webcamDimensions, setWebcamDimensions] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const currentTimeRef = useRef(0);
|
||||
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
|
||||
const selectedZoomIdRef = useRef<string | null>(null);
|
||||
@@ -901,6 +911,90 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const webcamVideo = webcamVideoRef.current;
|
||||
if (!webcamVideo || !webcamVideoPath) {
|
||||
setWebcamDimensions(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (webcamVideo.videoWidth > 0 && webcamVideo.videoHeight > 0) {
|
||||
setWebcamDimensions({
|
||||
width: webcamVideo.videoWidth,
|
||||
height: webcamVideo.videoHeight,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
webcamVideo.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
handleLoadedMetadata();
|
||||
return () => {
|
||||
webcamVideo.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
};
|
||||
}, [webcamVideoPath]);
|
||||
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage || !webcamDimensions) {
|
||||
setWebcamLayout(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateLayout = () => {
|
||||
const layout = computeWebcamOverlayLayout({
|
||||
stageWidth: stage.clientWidth,
|
||||
stageHeight: stage.clientHeight,
|
||||
videoWidth: webcamDimensions.width,
|
||||
videoHeight: webcamDimensions.height,
|
||||
});
|
||||
setWebcamLayout(layout);
|
||||
};
|
||||
|
||||
updateLayout();
|
||||
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateLayout);
|
||||
observer.observe(stage);
|
||||
return () => observer.disconnect();
|
||||
}, [webcamDimensions]);
|
||||
|
||||
useEffect(() => {
|
||||
const webcamVideo = webcamVideoRef.current;
|
||||
if (!webcamVideo || !webcamVideoPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlaying) {
|
||||
webcamVideo.pause();
|
||||
if (Math.abs(webcamVideo.currentTime - currentTime) > 0.05) {
|
||||
webcamVideo.currentTime = currentTime;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(webcamVideo.currentTime - currentTime) > 0.15) {
|
||||
webcamVideo.currentTime = currentTime;
|
||||
}
|
||||
|
||||
webcamVideo.play().catch(() => {
|
||||
// Ignore webcam autoplay restoration failures.
|
||||
});
|
||||
}, [currentTime, isPlaying, webcamVideoPath]);
|
||||
|
||||
useEffect(() => {
|
||||
const webcamVideo = webcamVideoRef.current;
|
||||
if (!webcamVideo || !webcamVideoPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
webcamVideo.pause();
|
||||
webcamVideo.currentTime = 0;
|
||||
}, [webcamVideoPath]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
(async () => {
|
||||
@@ -975,6 +1069,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={stageRef}
|
||||
className="relative rounded-sm overflow-hidden"
|
||||
style={{
|
||||
width: "100%",
|
||||
@@ -1008,12 +1103,33 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
{webcamVideoPath && (
|
||||
<video
|
||||
ref={webcamVideoRef}
|
||||
src={webcamVideoPath}
|
||||
className="absolute object-cover pointer-events-none"
|
||||
style={{
|
||||
left: webcamLayout?.x ?? 0,
|
||||
top: webcamLayout?.y ?? 0,
|
||||
width: webcamLayout?.width ?? 0,
|
||||
height: webcamLayout?.height ?? 0,
|
||||
borderRadius: webcamLayout?.borderRadius ?? 0,
|
||||
boxShadow: "0 12px 36px rgba(0,0,0,0.35), 0 4px 12px rgba(0,0,0,0.22)",
|
||||
zIndex: 20,
|
||||
opacity: webcamLayout ? 1 : 0,
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
muted
|
||||
preload="metadata"
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
{/* Only render overlay after PIXI and video are fully initialized */}
|
||||
{pixiReady && videoReady && (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="absolute inset-0 select-none"
|
||||
style={{ pointerEvents: "none" }}
|
||||
style={{ pointerEvents: "none", zIndex: 30 }}
|
||||
onPointerDown={handleOverlayPointerDown}
|
||||
onPointerMove={handleOverlayPointerMove}
|
||||
onPointerUp={handleOverlayPointerUp}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createProjectData,
|
||||
PROJECT_VERSION,
|
||||
resolveProjectMedia,
|
||||
validateProjectData,
|
||||
} from "./projectPersistence";
|
||||
|
||||
describe("projectPersistence media compatibility", () => {
|
||||
it("accepts legacy projects with a single videoPath", () => {
|
||||
const project = {
|
||||
version: 1,
|
||||
videoPath: "/tmp/screen.webm",
|
||||
editor: {},
|
||||
};
|
||||
|
||||
expect(validateProjectData(project)).toBe(true);
|
||||
expect(resolveProjectMedia(project)).toEqual({
|
||||
screenVideoPath: "/tmp/screen.webm",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates version 2 projects with explicit media", () => {
|
||||
const project = createProjectData(
|
||||
{
|
||||
screenVideoPath: "/tmp/screen.webm",
|
||||
webcamVideoPath: "/tmp/webcam.webm",
|
||||
},
|
||||
{
|
||||
wallpaper: "/wallpapers/wallpaper1.jpg",
|
||||
shadowIntensity: 0,
|
||||
showBlur: false,
|
||||
motionBlurAmount: 0,
|
||||
borderRadius: 0,
|
||||
padding: 50,
|
||||
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
|
||||
zoomRegions: [],
|
||||
trimRegions: [],
|
||||
speedRegions: [],
|
||||
annotationRegions: [],
|
||||
aspectRatio: "16:9",
|
||||
exportQuality: "good",
|
||||
exportFormat: "mp4",
|
||||
gifFrameRate: 15,
|
||||
gifLoop: true,
|
||||
gifSizePreset: "medium",
|
||||
},
|
||||
);
|
||||
|
||||
expect(project.version).toBe(PROJECT_VERSION);
|
||||
expect(project.media).toEqual({
|
||||
screenVideoPath: "/tmp/screen.webm",
|
||||
webcamVideoPath: "/tmp/webcam.webm",
|
||||
});
|
||||
expect(validateProjectData(project)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import type { ProjectMedia } from "@/lib/recordingSession";
|
||||
import { normalizeProjectMedia } from "@/lib/recordingSession";
|
||||
import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
@@ -22,7 +24,7 @@ export const WALLPAPER_PATHS = Array.from(
|
||||
(_, i) => `/wallpapers/wallpaper${i + 1}.jpg`,
|
||||
);
|
||||
|
||||
export const PROJECT_VERSION = 1;
|
||||
export const PROJECT_VERSION = 2;
|
||||
|
||||
export interface ProjectEditorState {
|
||||
wallpaper: string;
|
||||
@@ -46,8 +48,9 @@ export interface ProjectEditorState {
|
||||
|
||||
export interface EditorProjectData {
|
||||
version: number;
|
||||
videoPath: string;
|
||||
media?: ProjectMedia;
|
||||
editor: ProjectEditorState;
|
||||
videoPath?: string;
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
@@ -139,11 +142,26 @@ export function validateProjectData(candidate: unknown): candidate is EditorProj
|
||||
if (!candidate || typeof candidate !== "object") return false;
|
||||
const project = candidate as Partial<EditorProjectData>;
|
||||
if (typeof project.version !== "number") return false;
|
||||
if (typeof project.videoPath !== "string" || !project.videoPath) return false;
|
||||
if (!resolveProjectMedia(project)) return false;
|
||||
if (!project.editor || typeof project.editor !== "object") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveProjectMedia(
|
||||
candidate: Partial<EditorProjectData> | { media?: unknown; videoPath?: unknown },
|
||||
): ProjectMedia | null {
|
||||
const media = normalizeProjectMedia(candidate.media);
|
||||
if (media) {
|
||||
return media;
|
||||
}
|
||||
|
||||
if (typeof candidate.videoPath === "string" && candidate.videoPath.trim()) {
|
||||
return { screenVideoPath: candidate.videoPath };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
|
||||
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
|
||||
|
||||
@@ -346,12 +364,12 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
}
|
||||
|
||||
export function createProjectData(
|
||||
videoPath: string,
|
||||
media: ProjectMedia,
|
||||
editor: ProjectEditorState,
|
||||
): EditorProjectData {
|
||||
return {
|
||||
version: PROJECT_VERSION,
|
||||
videoPath,
|
||||
media,
|
||||
editor,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user