ada1f434f7
Adds a new 'No Webcam' option to the webcam layout preset dropdown in the editor. When selected, the webcam feed is completely hidden from both the preview and the exported video, allowing users who recorded with a webcam to exclude it from the final output. - Add 'no-webcam' to WebcamLayoutPreset type union and preset map - Handle 'no-webcam' in computeCompositeLayout (returns webcamRect: null) - Add 'no-webcam' case in project persistence normalization - Add 'No Webcam' option to the layout preset dropdown in SettingsPanel - Add 'noWebcam' i18n translation key (en)
581 lines
19 KiB
TypeScript
581 lines
19 KiB
TypeScript
import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects";
|
|
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
|
import type { ProjectMedia } from "@/lib/recordingSession";
|
|
import { normalizeProjectMedia } from "@/lib/recordingSession";
|
|
import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper";
|
|
import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
|
|
import {
|
|
type AnnotationRegion,
|
|
type CropRegion,
|
|
clampPlaybackSpeed,
|
|
DEFAULT_ANNOTATION_POSITION,
|
|
DEFAULT_ANNOTATION_SIZE,
|
|
DEFAULT_ANNOTATION_STYLE,
|
|
DEFAULT_BLUR_BLOCK_SIZE,
|
|
DEFAULT_BLUR_DATA,
|
|
DEFAULT_BLUR_FREEHAND_POINTS,
|
|
DEFAULT_BLUR_INTENSITY,
|
|
DEFAULT_CROP_REGION,
|
|
DEFAULT_FIGURE_DATA,
|
|
DEFAULT_PLAYBACK_SPEED,
|
|
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
|
DEFAULT_WEBCAM_MASK_SHAPE,
|
|
DEFAULT_WEBCAM_POSITION,
|
|
DEFAULT_WEBCAM_SIZE_PRESET,
|
|
DEFAULT_ZOOM_DEPTH,
|
|
MAX_BLUR_BLOCK_SIZE,
|
|
MAX_BLUR_INTENSITY,
|
|
MAX_PLAYBACK_SPEED,
|
|
MIN_BLUR_BLOCK_SIZE,
|
|
MIN_BLUR_INTENSITY,
|
|
MIN_PLAYBACK_SPEED,
|
|
type SpeedRegion,
|
|
type TrimRegion,
|
|
type WebcamLayoutPreset,
|
|
type WebcamMaskShape,
|
|
type WebcamPosition,
|
|
type WebcamSizePreset,
|
|
type ZoomRegion,
|
|
} from "./types";
|
|
|
|
const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const);
|
|
|
|
// Pre-fix projects could persist resolved file:// URLs (machine-specific) for
|
|
// bundled wallpapers. Rewrite only paths that match a known install layout
|
|
// (resources/[assets/]wallpapers for packaged, public/wallpapers for dev) so
|
|
// a legitimate user file that happens to live in a folder named "wallpapers"
|
|
// elsewhere is never silently replaced.
|
|
const LEGACY_FILE_WALLPAPER_RE =
|
|
/^file:\/\/.*?\/(?:resources\/(?:assets\/)?|public\/)wallpapers\/(wallpaper\d+\.jpg)$/i;
|
|
const CANONICAL_WALLPAPERS = new Set(WALLPAPER_PATHS);
|
|
|
|
function normalizeWallpaperValue(value: string): string {
|
|
const match = LEGACY_FILE_WALLPAPER_RE.exec(value);
|
|
if (!match) return value;
|
|
const canonical = `/wallpapers/${match[1]}`;
|
|
return CANONICAL_WALLPAPERS.has(canonical) ? canonical : DEFAULT_WALLPAPER;
|
|
}
|
|
|
|
export const PROJECT_VERSION = 2;
|
|
|
|
export interface ProjectEditorState {
|
|
wallpaper: string;
|
|
shadowIntensity: number;
|
|
showBlur: boolean;
|
|
motionBlurAmount: number;
|
|
borderRadius: number;
|
|
padding: number;
|
|
cropRegion: CropRegion;
|
|
zoomRegions: ZoomRegion[];
|
|
trimRegions: TrimRegion[];
|
|
speedRegions: SpeedRegion[];
|
|
annotationRegions: AnnotationRegion[];
|
|
aspectRatio: AspectRatio;
|
|
webcamLayoutPreset: WebcamLayoutPreset;
|
|
webcamMaskShape: WebcamMaskShape;
|
|
webcamSizePreset: WebcamSizePreset;
|
|
webcamPosition: WebcamPosition | null;
|
|
exportQuality: ExportQuality;
|
|
exportFormat: ExportFormat;
|
|
gifFrameRate: GifFrameRate;
|
|
gifLoop: boolean;
|
|
gifSizePreset: GifSizePreset;
|
|
cursorHighlight: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
|
|
}
|
|
|
|
export interface EditorProjectData {
|
|
version: number;
|
|
media?: ProjectMedia;
|
|
editor: ProjectEditorState;
|
|
videoPath?: string;
|
|
}
|
|
|
|
function isFiniteNumber(value: unknown): value is number {
|
|
return typeof value === "number" && Number.isFinite(value);
|
|
}
|
|
|
|
function computeNormalizedWebcamLayoutPreset(
|
|
webcamLayoutPreset: Partial<ProjectEditorState>["webcamLayoutPreset"],
|
|
normalizedAspectRatio: AspectRatio,
|
|
): WebcamLayoutPreset {
|
|
switch (webcamLayoutPreset) {
|
|
case "picture-in-picture":
|
|
case "no-webcam":
|
|
return webcamLayoutPreset;
|
|
case "vertical-stack":
|
|
return isPortraitAspectRatio(normalizedAspectRatio)
|
|
? webcamLayoutPreset
|
|
: DEFAULT_WEBCAM_LAYOUT_PRESET;
|
|
case "dual-frame":
|
|
return isPortraitAspectRatio(normalizedAspectRatio)
|
|
? DEFAULT_WEBCAM_LAYOUT_PRESET
|
|
: webcamLayoutPreset;
|
|
default:
|
|
return DEFAULT_WEBCAM_LAYOUT_PRESET;
|
|
}
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function isFileUrl(value: string): boolean {
|
|
return /^file:\/\//i.test(value);
|
|
}
|
|
|
|
function encodePathSegments(pathname: string, keepWindowsDrive = false): string {
|
|
return pathname
|
|
.split("/")
|
|
.map((segment, index) => {
|
|
if (!segment) return "";
|
|
if (keepWindowsDrive && index === 1 && /^[a-zA-Z]:$/.test(segment)) {
|
|
return segment;
|
|
}
|
|
return encodeURIComponent(segment);
|
|
})
|
|
.join("/");
|
|
}
|
|
|
|
export function toFileUrl(filePath: string): string {
|
|
const normalized = filePath.replace(/\\/g, "/");
|
|
|
|
// Windows drive path: C:/Users/...
|
|
if (/^[a-zA-Z]:\//.test(normalized)) {
|
|
return `file://${encodePathSegments(`/${normalized}`, true)}`;
|
|
}
|
|
|
|
// UNC path: //server/share/...
|
|
if (normalized.startsWith("//")) {
|
|
const [host, ...pathParts] = normalized.replace(/^\/+/, "").split("/");
|
|
const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/");
|
|
return encodedPath ? `file://${host}/${encodedPath}` : `file://${host}/`;
|
|
}
|
|
|
|
const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
|
return `file://${encodePathSegments(absolutePath)}`;
|
|
}
|
|
|
|
export function fromFileUrl(fileUrl: string): string {
|
|
const value = fileUrl.trim();
|
|
if (!isFileUrl(value)) {
|
|
return fileUrl;
|
|
}
|
|
|
|
try {
|
|
const url = new URL(value);
|
|
const pathname = decodeURIComponent(url.pathname);
|
|
|
|
if (url.host && url.host !== "localhost") {
|
|
return `//${url.host}${pathname}`;
|
|
}
|
|
|
|
if (/^\/[a-zA-Z]:/.test(pathname)) {
|
|
return pathname.slice(1);
|
|
}
|
|
|
|
return pathname;
|
|
} catch {
|
|
const rawFallbackPath = value.replace(/^file:\/\//i, "");
|
|
let fallbackPath = rawFallbackPath;
|
|
try {
|
|
fallbackPath = decodeURIComponent(rawFallbackPath);
|
|
} catch {
|
|
// Keep raw best-effort path if percent decoding fails.
|
|
}
|
|
return fallbackPath.replace(/^\/([a-zA-Z]:)/, "$1");
|
|
}
|
|
}
|
|
|
|
export function deriveNextId(prefix: string, ids: string[]): number {
|
|
const max = ids.reduce((acc, id) => {
|
|
const match = id.match(new RegExp(`^${prefix}-(\\d+)$`));
|
|
if (!match) return acc;
|
|
const value = Number(match[1]);
|
|
return Number.isFinite(value) ? Math.max(acc, value) : acc;
|
|
}, 0);
|
|
return max + 1;
|
|
}
|
|
|
|
export function validateProjectData(candidate: unknown): candidate is EditorProjectData {
|
|
if (!candidate || typeof candidate !== "object") return false;
|
|
const project = candidate as Partial<EditorProjectData>;
|
|
if (typeof project.version !== "number") 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);
|
|
const normalizedAspectRatio: AspectRatio = validAspectRatios.has(
|
|
editor.aspectRatio as AspectRatio,
|
|
)
|
|
? (editor.aspectRatio as AspectRatio)
|
|
: "16:9";
|
|
const normalizedWebcamLayoutPreset = computeNormalizedWebcamLayoutPreset(
|
|
editor.webcamLayoutPreset,
|
|
normalizedAspectRatio,
|
|
);
|
|
const normalizedWebcamPosition: WebcamPosition | null =
|
|
normalizedWebcamLayoutPreset === "picture-in-picture" &&
|
|
editor.webcamPosition &&
|
|
typeof editor.webcamPosition === "object" &&
|
|
isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) &&
|
|
isFiniteNumber((editor.webcamPosition as WebcamPosition).cy)
|
|
? {
|
|
cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
|
|
cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
|
|
}
|
|
: DEFAULT_WEBCAM_POSITION;
|
|
|
|
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
|
|
? editor.zoomRegions
|
|
.filter((region): region is ZoomRegion => Boolean(region && typeof region.id === "string"))
|
|
.map((region) => {
|
|
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
|
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
|
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
|
const endMs = Math.max(startMs + 1, rawEnd);
|
|
|
|
const validPreset =
|
|
region.rotationPreset === "iso" ||
|
|
region.rotationPreset === "left" ||
|
|
region.rotationPreset === "right"
|
|
? region.rotationPreset
|
|
: undefined;
|
|
return {
|
|
id: region.id,
|
|
startMs,
|
|
endMs,
|
|
depth: [1, 2, 3, 4, 5, 6].includes(region.depth) ? region.depth : DEFAULT_ZOOM_DEPTH,
|
|
focus: {
|
|
cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1),
|
|
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
|
|
},
|
|
focusMode: region.focusMode === "auto" ? "auto" : "manual",
|
|
...(validPreset ? { rotationPreset: validPreset } : {}),
|
|
};
|
|
})
|
|
: [];
|
|
|
|
const normalizedTrimRegions: TrimRegion[] = Array.isArray(editor.trimRegions)
|
|
? editor.trimRegions
|
|
.filter((region): region is TrimRegion => Boolean(region && typeof region.id === "string"))
|
|
.map((region) => {
|
|
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
|
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
|
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
|
const endMs = Math.max(startMs + 1, rawEnd);
|
|
return {
|
|
id: region.id,
|
|
startMs,
|
|
endMs,
|
|
};
|
|
})
|
|
: [];
|
|
|
|
const normalizedSpeedRegions: SpeedRegion[] = Array.isArray(editor.speedRegions)
|
|
? editor.speedRegions
|
|
.filter((region): region is SpeedRegion => Boolean(region && typeof region.id === "string"))
|
|
.map((region) => {
|
|
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
|
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
|
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
|
const endMs = Math.max(startMs + 1, rawEnd);
|
|
|
|
const speed =
|
|
isFiniteNumber(region.speed) &&
|
|
region.speed >= MIN_PLAYBACK_SPEED &&
|
|
region.speed <= MAX_PLAYBACK_SPEED
|
|
? clampPlaybackSpeed(region.speed)
|
|
: DEFAULT_PLAYBACK_SPEED;
|
|
|
|
return {
|
|
id: region.id,
|
|
startMs,
|
|
endMs,
|
|
speed,
|
|
};
|
|
})
|
|
: [];
|
|
|
|
const normalizedAnnotationRegions: AnnotationRegion[] = Array.isArray(editor.annotationRegions)
|
|
? editor.annotationRegions
|
|
.filter((region): region is AnnotationRegion =>
|
|
Boolean(region && typeof region.id === "string"),
|
|
)
|
|
.map((region, index) => {
|
|
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
|
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
|
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
|
const endMs = Math.max(startMs + 1, rawEnd);
|
|
const blurShape =
|
|
typeof region.blurData?.shape === "string" &&
|
|
VALID_BLUR_SHAPES.has(region.blurData.shape)
|
|
? region.blurData.shape
|
|
: DEFAULT_BLUR_DATA.shape;
|
|
const blurType = normalizeBlurType(region.blurData?.type);
|
|
const blurColor = normalizeBlurColor(region.blurData?.color);
|
|
|
|
return {
|
|
id: region.id,
|
|
startMs,
|
|
endMs,
|
|
type:
|
|
region.type === "image" || region.type === "figure" || region.type === "blur"
|
|
? region.type
|
|
: "text",
|
|
content: typeof region.content === "string" ? region.content : "",
|
|
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
|
|
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
|
|
position: {
|
|
x: clamp(
|
|
isFiniteNumber(region.position?.x)
|
|
? region.position.x
|
|
: DEFAULT_ANNOTATION_POSITION.x,
|
|
0,
|
|
100,
|
|
),
|
|
y: clamp(
|
|
isFiniteNumber(region.position?.y)
|
|
? region.position.y
|
|
: DEFAULT_ANNOTATION_POSITION.y,
|
|
0,
|
|
100,
|
|
),
|
|
},
|
|
size: {
|
|
width: clamp(
|
|
isFiniteNumber(region.size?.width)
|
|
? region.size.width
|
|
: DEFAULT_ANNOTATION_SIZE.width,
|
|
1,
|
|
200,
|
|
),
|
|
height: clamp(
|
|
isFiniteNumber(region.size?.height)
|
|
? region.size.height
|
|
: DEFAULT_ANNOTATION_SIZE.height,
|
|
1,
|
|
200,
|
|
),
|
|
},
|
|
style: {
|
|
...DEFAULT_ANNOTATION_STYLE,
|
|
...(region.style && typeof region.style === "object" ? region.style : {}),
|
|
},
|
|
zIndex: isFiniteNumber(region.zIndex) ? region.zIndex : index + 1,
|
|
figureData: region.figureData
|
|
? {
|
|
...DEFAULT_FIGURE_DATA,
|
|
...region.figureData,
|
|
}
|
|
: undefined,
|
|
blurData:
|
|
region.blurData && typeof region.blurData === "object"
|
|
? {
|
|
...DEFAULT_BLUR_DATA,
|
|
...region.blurData,
|
|
type: blurType,
|
|
shape: blurShape,
|
|
color: blurColor,
|
|
intensity: isFiniteNumber(region.blurData.intensity)
|
|
? clamp(region.blurData.intensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY)
|
|
: DEFAULT_BLUR_INTENSITY,
|
|
blockSize: isFiniteNumber(region.blurData.blockSize)
|
|
? clamp(region.blurData.blockSize, MIN_BLUR_BLOCK_SIZE, MAX_BLUR_BLOCK_SIZE)
|
|
: DEFAULT_BLUR_BLOCK_SIZE,
|
|
freehandPoints: Array.isArray(region.blurData.freehandPoints)
|
|
? region.blurData.freehandPoints
|
|
.filter(
|
|
(
|
|
point,
|
|
): point is {
|
|
x: number;
|
|
y: number;
|
|
} =>
|
|
Boolean(
|
|
point &&
|
|
isFiniteNumber((point as { x?: unknown }).x) &&
|
|
isFiniteNumber((point as { y?: unknown }).y),
|
|
),
|
|
)
|
|
.map((point) => ({
|
|
x: clamp(point.x, 0, 100),
|
|
y: clamp(point.y, 0, 100),
|
|
}))
|
|
: DEFAULT_BLUR_FREEHAND_POINTS,
|
|
}
|
|
: undefined,
|
|
};
|
|
})
|
|
: [];
|
|
|
|
const rawCropX = isFiniteNumber(editor.cropRegion?.x)
|
|
? editor.cropRegion.x
|
|
: DEFAULT_CROP_REGION.x;
|
|
const rawCropY = isFiniteNumber(editor.cropRegion?.y)
|
|
? editor.cropRegion.y
|
|
: DEFAULT_CROP_REGION.y;
|
|
const rawCropWidth = isFiniteNumber(editor.cropRegion?.width)
|
|
? editor.cropRegion.width
|
|
: DEFAULT_CROP_REGION.width;
|
|
const rawCropHeight = isFiniteNumber(editor.cropRegion?.height)
|
|
? editor.cropRegion.height
|
|
: DEFAULT_CROP_REGION.height;
|
|
|
|
const cropX = clamp(rawCropX, 0, 1);
|
|
const cropY = clamp(rawCropY, 0, 1);
|
|
const cropWidth = clamp(rawCropWidth, 0.01, 1 - cropX);
|
|
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
|
|
|
|
return {
|
|
wallpaper:
|
|
typeof editor.wallpaper === "string"
|
|
? normalizeWallpaperValue(editor.wallpaper)
|
|
: DEFAULT_WALLPAPER,
|
|
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
|
|
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
|
|
motionBlurAmount: isFiniteNumber(editor.motionBlurAmount)
|
|
? clamp(editor.motionBlurAmount, 0, 1)
|
|
: typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean"
|
|
? (editor as { motionBlurEnabled?: boolean }).motionBlurEnabled
|
|
? 0.35
|
|
: 0
|
|
: 0,
|
|
borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 0,
|
|
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50,
|
|
cropRegion: {
|
|
x: cropX,
|
|
y: cropY,
|
|
width: cropWidth,
|
|
height: cropHeight,
|
|
},
|
|
zoomRegions: normalizedZoomRegions,
|
|
trimRegions: normalizedTrimRegions,
|
|
speedRegions: normalizedSpeedRegions,
|
|
annotationRegions: normalizedAnnotationRegions,
|
|
aspectRatio: normalizedAspectRatio,
|
|
webcamLayoutPreset: normalizedWebcamLayoutPreset,
|
|
webcamMaskShape:
|
|
editor.webcamMaskShape === "rectangle" ||
|
|
editor.webcamMaskShape === "circle" ||
|
|
editor.webcamMaskShape === "square" ||
|
|
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: normalizedWebcamPosition,
|
|
exportQuality:
|
|
editor.exportQuality === "medium" || editor.exportQuality === "source"
|
|
? editor.exportQuality
|
|
: "good",
|
|
exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4",
|
|
gifFrameRate:
|
|
editor.gifFrameRate === 15 ||
|
|
editor.gifFrameRate === 20 ||
|
|
editor.gifFrameRate === 25 ||
|
|
editor.gifFrameRate === 30
|
|
? editor.gifFrameRate
|
|
: 15,
|
|
gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true,
|
|
gifSizePreset:
|
|
editor.gifSizePreset === "medium" ||
|
|
editor.gifSizePreset === "large" ||
|
|
editor.gifSizePreset === "original"
|
|
? editor.gifSizePreset
|
|
: "medium",
|
|
cursorHighlight: normalizeCursorHighlight(editor.cursorHighlight),
|
|
};
|
|
}
|
|
|
|
function normalizeCursorHighlight(
|
|
value: unknown,
|
|
): import("./videoPlayback/cursorHighlight").CursorHighlightConfig {
|
|
const fallback: import("./videoPlayback/cursorHighlight").CursorHighlightConfig = {
|
|
enabled: false,
|
|
style: "ring",
|
|
sizePx: 24,
|
|
color: "#FFD700",
|
|
opacity: 0.9,
|
|
onlyOnClicks: false,
|
|
clickEmphasisDurationMs: 350,
|
|
offsetXNorm: 0,
|
|
offsetYNorm: 0,
|
|
};
|
|
if (!value || typeof value !== "object") return fallback;
|
|
const v = value as Partial<import("./videoPlayback/cursorHighlight").CursorHighlightConfig>;
|
|
return {
|
|
enabled: typeof v.enabled === "boolean" ? v.enabled : fallback.enabled,
|
|
style: v.style === "dot" || v.style === "ring" ? v.style : fallback.style,
|
|
sizePx:
|
|
typeof v.sizePx === "number" && v.sizePx >= 10 && v.sizePx <= 36 ? v.sizePx : fallback.sizePx,
|
|
color:
|
|
typeof v.color === "string" && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v.color)
|
|
? v.color
|
|
: fallback.color,
|
|
opacity:
|
|
typeof v.opacity === "number" && v.opacity >= 0 && v.opacity <= 1
|
|
? v.opacity
|
|
: fallback.opacity,
|
|
onlyOnClicks: typeof v.onlyOnClicks === "boolean" ? v.onlyOnClicks : fallback.onlyOnClicks,
|
|
clickEmphasisDurationMs:
|
|
typeof v.clickEmphasisDurationMs === "number" && v.clickEmphasisDurationMs > 0
|
|
? v.clickEmphasisDurationMs
|
|
: fallback.clickEmphasisDurationMs,
|
|
offsetXNorm:
|
|
typeof v.offsetXNorm === "number" && Number.isFinite(v.offsetXNorm)
|
|
? Math.max(-1, Math.min(1, v.offsetXNorm))
|
|
: fallback.offsetXNorm,
|
|
offsetYNorm:
|
|
typeof v.offsetYNorm === "number" && Number.isFinite(v.offsetYNorm)
|
|
? Math.max(-1, Math.min(1, v.offsetYNorm))
|
|
: fallback.offsetYNorm,
|
|
};
|
|
}
|
|
|
|
export function createProjectData(
|
|
media: ProjectMedia,
|
|
editor: ProjectEditorState,
|
|
): EditorProjectData {
|
|
return {
|
|
version: PROJECT_VERSION,
|
|
media,
|
|
editor,
|
|
};
|
|
}
|
|
|
|
export function createProjectSnapshot(
|
|
media: ProjectMedia,
|
|
editor: Partial<ProjectEditorState>,
|
|
): string {
|
|
return JSON.stringify(createProjectData(media, normalizeProjectEditor(editor)));
|
|
}
|
|
|
|
export function hasProjectUnsavedChanges(
|
|
currentSnapshot: string | null,
|
|
baselineSnapshot: string | null,
|
|
): boolean {
|
|
return Boolean(
|
|
currentSnapshot !== null && baselineSnapshot !== null && currentSnapshot !== baselineSnapshot,
|
|
);
|
|
}
|