Merge pull request #369 from webadderallorg/fix/project-autosave-media-path-handling
Improve project autosave and media path handling
@@ -398,12 +398,15 @@ interface Window {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
|
||||
setCurrentVideoPath: (
|
||||
path: string,
|
||||
options?: { preserveProjectPath?: boolean },
|
||||
) => Promise<{ success: boolean; webcamPath: string | null }>;
|
||||
setCurrentRecordingSession: (session: {
|
||||
videoPath: string;
|
||||
webcamPath?: string | null;
|
||||
timeOffsetMs?: number;
|
||||
}) => Promise<{ success: boolean }>;
|
||||
}, options?: { preserveProjectPath?: boolean }) => Promise<{ success: boolean }>;
|
||||
getCurrentRecordingSession: () => Promise<{
|
||||
success: boolean;
|
||||
session?: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number };
|
||||
|
||||
@@ -79,12 +79,13 @@ describe("local media path policy", () => {
|
||||
const videoPath = path.join(downloadsPath, "external-video.mp4");
|
||||
await fs.mkdir(downloadsPath, { recursive: true });
|
||||
await fs.writeFile(videoPath, "test-video");
|
||||
const resolvedVideoPath = await fs.realpath(videoPath);
|
||||
|
||||
const { resolveApprovedLocalMediaPath } = await import("./manager");
|
||||
const { isAllowedMediaPath } = await import("../../mediaServer");
|
||||
|
||||
expect(isAllowedMediaPath(videoPath)).toBe(false);
|
||||
await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBe(videoPath);
|
||||
await expect(resolveApprovedLocalMediaPath(videoPath)).resolves.toBe(resolvedVideoPath);
|
||||
expect(isAllowedMediaPath(videoPath)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -100,4 +101,18 @@ describe("local media path policy", () => {
|
||||
await expect(resolveApprovedLocalMediaPath(textPath)).resolves.toBeNull();
|
||||
expect(isAllowedMediaPath(textPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves an existing project thumbnail when no replacement is provided", async () => {
|
||||
const projectPath = path.join(tempRoot, "Projects", "demo.recordly");
|
||||
const thumbnailDataUrl = `data:image/png;base64,${Buffer.from("png-thumbnail").toString("base64")}`;
|
||||
await fs.mkdir(path.dirname(projectPath), { recursive: true });
|
||||
|
||||
const { getProjectThumbnailPath, saveProjectThumbnail } = await import("./manager");
|
||||
const thumbnailPath = getProjectThumbnailPath(projectPath);
|
||||
|
||||
await saveProjectThumbnail(projectPath, thumbnailDataUrl);
|
||||
await saveProjectThumbnail(projectPath, undefined);
|
||||
|
||||
await expect(fs.readFile(thumbnailPath, "utf8")).resolves.toBe("png-thumbnail");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,19 +68,36 @@ export async function isAllowedLocalMediaPath(candidatePath: string) {
|
||||
return isAllowedLocalReadPath(normalizedCandidatePath);
|
||||
}
|
||||
|
||||
async function collectApprovedLocalReadPaths(filePath?: string | null): Promise<string[]> {
|
||||
const normalizedPath = normalizeVideoSourcePath(filePath);
|
||||
if (!normalizedPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const approvedPaths = [normalizePath(normalizedPath)];
|
||||
|
||||
try {
|
||||
const realPath = await fs.realpath(approvedPaths[0]);
|
||||
const normalizedRealPath = normalizePath(realPath);
|
||||
if (!approvedPaths.includes(normalizedRealPath)) {
|
||||
approvedPaths.push(normalizedRealPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing files; the eventual read will surface the real error.
|
||||
}
|
||||
|
||||
return approvedPaths;
|
||||
}
|
||||
|
||||
export async function rememberApprovedLocalReadPath(filePath?: string | null) {
|
||||
const normalizedPath = normalizeVideoSourcePath(filePath);
|
||||
if (!normalizedPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = normalizePath(normalizedPath);
|
||||
approvedLocalReadPaths.add(resolvedPath);
|
||||
|
||||
try {
|
||||
approvedLocalReadPaths.add(await fs.realpath(resolvedPath));
|
||||
} catch {
|
||||
// Ignore missing files; the eventual read will surface the real error.
|
||||
const approvedPaths = await collectApprovedLocalReadPaths(normalizedPath);
|
||||
for (const approvedPath of approvedPaths) {
|
||||
approvedLocalReadPaths.add(approvedPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,13 +118,26 @@ export async function resolveApprovedLocalMediaPath(candidatePath: string): Prom
|
||||
return null;
|
||||
}
|
||||
|
||||
await rememberApprovedLocalReadPath(realPath);
|
||||
await rememberApprovedLocalReadPath(candidatePath);
|
||||
return realPath;
|
||||
}
|
||||
|
||||
export async function replaceApprovedSessionLocalReadPaths(filePaths: Array<string | null | undefined>) {
|
||||
const nextApprovedPaths = new Set<string>();
|
||||
const approvedPathLists = await Promise.all(
|
||||
filePaths.map((filePath) => collectApprovedLocalReadPaths(filePath)),
|
||||
);
|
||||
|
||||
for (const approvedPathList of approvedPathLists) {
|
||||
for (const approvedPath of approvedPathList) {
|
||||
nextApprovedPaths.add(approvedPath);
|
||||
}
|
||||
}
|
||||
|
||||
approvedLocalReadPaths.clear();
|
||||
await Promise.all(filePaths.map((filePath) => rememberApprovedLocalReadPath(filePath)));
|
||||
for (const approvedPath of nextApprovedPaths) {
|
||||
approvedLocalReadPaths.add(approvedPath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveProjectMediaSources(project: unknown): Promise<
|
||||
@@ -196,6 +226,10 @@ export function getProjectThumbnailPath(projectPath: string) {
|
||||
|
||||
export async function saveProjectThumbnail(projectPath: string, thumbnailDataUrl?: string | null) {
|
||||
const thumbnailPath = getProjectThumbnailPath(projectPath);
|
||||
if (thumbnailDataUrl === undefined) {
|
||||
return existsSync(thumbnailPath) ? thumbnailPath : null;
|
||||
}
|
||||
|
||||
if (!thumbnailDataUrl) {
|
||||
await fs.rm(thumbnailPath, { force: true }).catch(() => undefined);
|
||||
return null;
|
||||
@@ -383,4 +417,3 @@ export function isTrustedProjectPath(filePath?: string | null): boolean {
|
||||
if (!filePath || !currentProjectPath) return false;
|
||||
return normalizePath(filePath) === normalizePath(currentProjectPath);
|
||||
}
|
||||
|
||||
|
||||
@@ -527,7 +527,7 @@ export function registerProjectHandlers() {
|
||||
return { success: false, error: String(error), message: 'Failed to open projects folder.' }
|
||||
}
|
||||
})
|
||||
ipcMain.handle('set-current-video-path', async (_, path: string) => {
|
||||
ipcMain.handle('set-current-video-path', async (_, path: string, options?: { preserveProjectPath?: boolean }) => {
|
||||
setCurrentVideoPath(normalizeVideoSourcePath(path) ?? path)
|
||||
approveUserPath(currentVideoPath)
|
||||
const resolvedSession = await resolveRecordingSession(currentVideoPath)
|
||||
@@ -547,11 +547,13 @@ export function registerProjectHandlers() {
|
||||
await persistRecordingSessionManifest(resolvedSession)
|
||||
}
|
||||
|
||||
setCurrentProjectPath(null)
|
||||
if (!options?.preserveProjectPath) {
|
||||
setCurrentProjectPath(null)
|
||||
}
|
||||
return { success: true, webcamPath: resolvedSession.webcamPath ?? null }
|
||||
})
|
||||
|
||||
ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }) => {
|
||||
ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number }, options?: { preserveProjectPath?: boolean }) => {
|
||||
const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) ?? session.videoPath
|
||||
setCurrentVideoPath(normalizedVideoPath)
|
||||
setCurrentRecordingSession({
|
||||
@@ -563,7 +565,9 @@ export function registerProjectHandlers() {
|
||||
currentRecordingSession!.videoPath,
|
||||
currentRecordingSession!.webcamPath,
|
||||
])
|
||||
setCurrentProjectPath(null)
|
||||
if (!options?.preserveProjectPath) {
|
||||
setCurrentProjectPath(null)
|
||||
}
|
||||
await persistRecordingSessionManifest(currentRecordingSession!)
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
@@ -430,15 +430,15 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
}) => {
|
||||
return ipcRenderer.invoke("generate-auto-captions", options);
|
||||
},
|
||||
setCurrentVideoPath: (path: string) => {
|
||||
return ipcRenderer.invoke("set-current-video-path", path);
|
||||
setCurrentVideoPath: (path: string, options?: { preserveProjectPath?: boolean }) => {
|
||||
return ipcRenderer.invoke("set-current-video-path", path, options);
|
||||
},
|
||||
setCurrentRecordingSession: (session: {
|
||||
videoPath: string;
|
||||
webcamPath?: string | null;
|
||||
timeOffsetMs?: number;
|
||||
}) => {
|
||||
return ipcRenderer.invoke("set-current-recording-session", session);
|
||||
}, options?: { preserveProjectPath?: boolean }) => {
|
||||
return ipcRenderer.invoke("set-current-recording-session", session, options);
|
||||
},
|
||||
getCurrentRecordingSession: () => {
|
||||
return ipcRenderer.invoke("get-current-recording-session");
|
||||
|
||||
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 82 KiB |
@@ -1777,14 +1777,18 @@ export function SettingsPanel({
|
||||
<div
|
||||
key={g}
|
||||
className={wallpaperTileClass(gradient === g)}
|
||||
style={{ background: g }}
|
||||
aria-label={`Gradient ${idx + 1}`}
|
||||
onClick={() => {
|
||||
setGradient(g);
|
||||
onWallpaperChange(g);
|
||||
}}
|
||||
role="button"
|
||||
/>
|
||||
>
|
||||
<div
|
||||
className="absolute inset-[1px] overflow-hidden rounded-[8px]"
|
||||
style={{ background: g }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -226,6 +226,13 @@ type SmokeExportConfig = {
|
||||
fps?: ExportMp4FrameRate;
|
||||
};
|
||||
|
||||
type SaveProjectOptions = {
|
||||
silent?: boolean;
|
||||
remountPreviewAfterSave?: boolean;
|
||||
refreshLibraryAfterSave?: boolean;
|
||||
captureThumbnail?: boolean;
|
||||
};
|
||||
|
||||
async function writeSmokeExportReport(
|
||||
outputPath: string | null,
|
||||
report: Record<string, unknown>,
|
||||
@@ -251,6 +258,7 @@ async function writeSmokeExportReport(
|
||||
|
||||
const DEFAULT_MP4_EXPORT_FRAME_RATE: ExportMp4FrameRate = 30;
|
||||
const SOURCE_AUDIO_FALLBACK_TOAST_ID = "source-audio-fallback-error";
|
||||
const PROJECT_AUTOSAVE_DELAY_MS = 1000;
|
||||
|
||||
function getEncodingModeBitrateMultiplier(encodingMode: ExportEncodingMode): number {
|
||||
switch (encodingMode) {
|
||||
@@ -695,6 +703,8 @@ export default function VideoEditor() {
|
||||
const cropSnapshotRef = useRef<CropRegion | null>(null);
|
||||
const mp4SupportRequestRef = useRef(0);
|
||||
const smokeExportStartedRef = useRef(false);
|
||||
const projectAutosaveTimeoutRef = useRef<number | null>(null);
|
||||
const projectSaveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const [historyVersion, setHistoryVersion] = useState(0);
|
||||
const timelineRef = useRef<TimelineEditorHandle>(null);
|
||||
|
||||
@@ -982,6 +992,19 @@ export default function VideoEditor() {
|
||||
setPreviewVersion((version) => version + 1);
|
||||
}, []);
|
||||
|
||||
const clearPendingProjectAutosave = useCallback(() => {
|
||||
if (projectAutosaveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(projectAutosaveTimeoutRef.current);
|
||||
projectAutosaveTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const queueProjectSave = useCallback((task: () => Promise<boolean>) => {
|
||||
const run = projectSaveQueueRef.current.catch(() => undefined).then(task);
|
||||
projectSaveQueueRef.current = run.catch(() => undefined);
|
||||
return run;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
exporterRef.current?.cancel();
|
||||
@@ -999,6 +1022,10 @@ export default function VideoEditor() {
|
||||
window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current);
|
||||
pendingFreshRecordingAutoSuggestTimeoutRef.current = null;
|
||||
}
|
||||
if (projectAutosaveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(projectAutosaveTimeoutRef.current);
|
||||
projectAutosaveTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -1561,20 +1588,24 @@ 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?.({
|
||||
videoPath: sourcePath,
|
||||
webcamPath: normalizedEditor.webcam.sourcePath,
|
||||
timeOffsetMs: normalizedEditor.webcam.timeOffsetMs,
|
||||
});
|
||||
} else {
|
||||
await window.electronAPI.setCurrentVideoPath(sourcePath);
|
||||
}
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
setWallpaper(normalizedEditor.wallpaper);
|
||||
setShadowIntensity(normalizedEditor.shadowIntensity);
|
||||
@@ -1711,9 +1742,11 @@ export default function VideoEditor() {
|
||||
: webcamPath
|
||||
? webcam.timeOffsetMs
|
||||
: DEFAULT_WEBCAM_TIME_OFFSET_MS,
|
||||
}, {
|
||||
preserveProjectPath: Boolean(currentProjectPath),
|
||||
});
|
||||
},
|
||||
[currentSourcePath, webcam.timeOffsetMs],
|
||||
[currentProjectPath, currentSourcePath, webcam.timeOffsetMs],
|
||||
);
|
||||
|
||||
const syncActiveVideoSource = useCallback(
|
||||
@@ -1723,13 +1756,17 @@ export default function VideoEditor() {
|
||||
videoPath: sourcePath,
|
||||
webcamPath,
|
||||
timeOffsetMs: webcam.timeOffsetMs,
|
||||
}, {
|
||||
preserveProjectPath: Boolean(currentProjectPath),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await window.electronAPI.setCurrentVideoPath(sourcePath);
|
||||
await window.electronAPI.setCurrentVideoPath(sourcePath, {
|
||||
preserveProjectPath: Boolean(currentProjectPath),
|
||||
});
|
||||
},
|
||||
[webcam.timeOffsetMs],
|
||||
[currentProjectPath, webcam.timeOffsetMs],
|
||||
);
|
||||
|
||||
const handleUploadWebcam = useCallback(async () => {
|
||||
@@ -2237,83 +2274,106 @@ export default function VideoEditor() {
|
||||
}, []);
|
||||
|
||||
const saveProject = useCallback(
|
||||
async (forceSaveAs: boolean) => {
|
||||
if (!currentSourcePath) {
|
||||
toast.error("No video loaded");
|
||||
return false;
|
||||
}
|
||||
async (forceSaveAs: boolean, options?: SaveProjectOptions) => {
|
||||
clearPendingProjectAutosave();
|
||||
return queueProjectSave(async () => {
|
||||
if (!currentSourcePath) {
|
||||
if (!options?.silent) {
|
||||
toast.error("No video loaded");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const projectData =
|
||||
currentProjectSnapshot?.videoPath === currentSourcePath
|
||||
? currentProjectSnapshot
|
||||
: createProjectData(
|
||||
currentSourcePath,
|
||||
currentPersistedEditorState,
|
||||
lastSavedSnapshot?.projectId ?? null,
|
||||
);
|
||||
const shouldCaptureThumbnail = options?.captureThumbnail ?? true;
|
||||
const shouldRefreshLibrary = options?.refreshLibraryAfterSave ?? true;
|
||||
const shouldRemountPreview = options?.remountPreviewAfterSave ?? true;
|
||||
|
||||
const fileNameBase =
|
||||
currentSourcePath
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`;
|
||||
let targetProjectPath = forceSaveAs ? undefined : (currentProjectPath ?? undefined);
|
||||
try {
|
||||
const projectData =
|
||||
currentProjectSnapshot?.videoPath === currentSourcePath
|
||||
? currentProjectSnapshot
|
||||
: createProjectData(
|
||||
currentSourcePath,
|
||||
currentPersistedEditorState,
|
||||
lastSavedSnapshot?.projectId ?? null,
|
||||
);
|
||||
|
||||
if (!forceSaveAs && !targetProjectPath) {
|
||||
const activeProjectResult = await window.electronAPI.loadCurrentProjectFile();
|
||||
if (activeProjectResult.success && activeProjectResult.path) {
|
||||
targetProjectPath = activeProjectResult.path;
|
||||
setCurrentProjectPath(activeProjectResult.path);
|
||||
const fileNameBase =
|
||||
currentSourcePath
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.replace(/\.[^.]+$/, "") || `project-${Date.now()}`;
|
||||
let targetProjectPath = forceSaveAs ? undefined : (currentProjectPath ?? undefined);
|
||||
|
||||
if (!forceSaveAs && !targetProjectPath) {
|
||||
const activeProjectResult = await window.electronAPI.loadCurrentProjectFile();
|
||||
if (activeProjectResult.success && activeProjectResult.path) {
|
||||
targetProjectPath = activeProjectResult.path;
|
||||
setCurrentProjectPath(activeProjectResult.path);
|
||||
}
|
||||
}
|
||||
|
||||
const thumbnailDataUrl = shouldCaptureThumbnail
|
||||
? await captureProjectThumbnail()
|
||||
: undefined;
|
||||
|
||||
const result = await window.electronAPI.saveProjectFile(
|
||||
projectData,
|
||||
fileNameBase,
|
||||
targetProjectPath,
|
||||
thumbnailDataUrl,
|
||||
);
|
||||
|
||||
if (result.canceled) {
|
||||
if (!options?.silent) {
|
||||
toast.info("Project save canceled");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
if (!options?.silent) {
|
||||
toast.error(result.message || "Failed to save project");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.path) {
|
||||
setCurrentProjectPath(result.path);
|
||||
}
|
||||
setLastSavedSnapshot(
|
||||
cloneStructured(
|
||||
createProjectData(
|
||||
projectData.videoPath,
|
||||
projectData.editor,
|
||||
result.projectId ?? projectData.projectId ?? null,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (shouldRefreshLibrary) {
|
||||
await refreshProjectLibrary();
|
||||
}
|
||||
|
||||
if (!options?.silent) {
|
||||
toast.success(`Project saved to ${result.path}`);
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
if (shouldRemountPreview) {
|
||||
remountPreview();
|
||||
}
|
||||
}
|
||||
|
||||
const thumbnailDataUrl = await captureProjectThumbnail();
|
||||
|
||||
const result = await window.electronAPI.saveProjectFile(
|
||||
projectData,
|
||||
fileNameBase,
|
||||
targetProjectPath,
|
||||
thumbnailDataUrl,
|
||||
);
|
||||
|
||||
if (result.canceled) {
|
||||
toast.info("Project save canceled");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.message || "Failed to save project");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.path) {
|
||||
setCurrentProjectPath(result.path);
|
||||
}
|
||||
setLastSavedSnapshot(
|
||||
cloneStructured(
|
||||
createProjectData(
|
||||
projectData.videoPath,
|
||||
projectData.editor,
|
||||
result.projectId ?? projectData.projectId ?? null,
|
||||
),
|
||||
),
|
||||
);
|
||||
await refreshProjectLibrary();
|
||||
|
||||
toast.success(`Project saved to ${result.path}`);
|
||||
return true;
|
||||
} finally {
|
||||
remountPreview();
|
||||
}
|
||||
});
|
||||
},
|
||||
[
|
||||
captureProjectThumbnail,
|
||||
clearPendingProjectAutosave,
|
||||
currentSourcePath,
|
||||
currentProjectPath,
|
||||
currentProjectSnapshot,
|
||||
currentPersistedEditorState,
|
||||
lastSavedSnapshot?.projectId,
|
||||
queueProjectSave,
|
||||
refreshProjectLibrary,
|
||||
remountPreview,
|
||||
],
|
||||
@@ -2342,6 +2402,27 @@ export default function VideoEditor() {
|
||||
}
|
||||
}, [saveProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProjectPath || !hasUnsavedChanges) {
|
||||
clearPendingProjectAutosave();
|
||||
return;
|
||||
}
|
||||
|
||||
projectAutosaveTimeoutRef.current = window.setTimeout(() => {
|
||||
projectAutosaveTimeoutRef.current = null;
|
||||
void saveProject(false, {
|
||||
silent: true,
|
||||
remountPreviewAfterSave: false,
|
||||
refreshLibraryAfterSave: false,
|
||||
captureThumbnail: false,
|
||||
});
|
||||
}, PROJECT_AUTOSAVE_DELAY_MS);
|
||||
|
||||
return () => {
|
||||
clearPendingProjectAutosave();
|
||||
};
|
||||
}, [clearPendingProjectAutosave, currentProjectPath, hasUnsavedChanges, saveProject]);
|
||||
|
||||
/**
|
||||
* Saves the current project directly into the projects library under a chosen name.
|
||||
*/
|
||||
|
||||
@@ -240,7 +240,7 @@ describe("editorPreferences", () => {
|
||||
cursorClickBounceDuration: 350,
|
||||
cursorSway: 1.5,
|
||||
borderRadius: 18,
|
||||
padding: 30,
|
||||
padding: { top: 30, right: 30, bottom: 30, left: 30, linked: true },
|
||||
frame: DEFAULT_EDITOR_PREFERENCES.frame,
|
||||
aspectRatio: "4:5",
|
||||
exportEncodingMode: "quality",
|
||||
@@ -282,7 +282,7 @@ describe("editorPreferences", () => {
|
||||
cursorClickBounceDuration: 350,
|
||||
cursorSway: 1.5,
|
||||
borderRadius: 18,
|
||||
padding: 30,
|
||||
padding: { top: 30, right: 30, bottom: 30, left: 30, linked: true },
|
||||
frame: DEFAULT_EDITOR_PREFERENCES.frame,
|
||||
aspectRatio: "4:5",
|
||||
exportEncodingMode: "quality",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { fromFileUrl, toFileUrl } from "@/components/video-editor/projectPersist
|
||||
const NOOP = () => undefined;
|
||||
const REMOTE_MEDIA_URL_PATTERN = /^(https?:|blob:|data:)/i;
|
||||
const LOOPBACK_MEDIA_HOSTS = new Set(["127.0.0.1", "localhost"]);
|
||||
const BUNDLED_ASSET_PATH_PREFIXES = ["/wallpapers/", "/app-icons/"];
|
||||
|
||||
export function isAbsoluteLocalPath(resource: string) {
|
||||
return (
|
||||
@@ -12,6 +13,10 @@ export function isAbsoluteLocalPath(resource: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function isBundledAssetPath(resource: string) {
|
||||
return BUNDLED_ASSET_PATH_PREFIXES.some((prefix) => resource.startsWith(prefix));
|
||||
}
|
||||
|
||||
function getLocalMediaServerPath(resource: string) {
|
||||
if (!/^https?:\/\//i.test(resource)) {
|
||||
return null;
|
||||
@@ -44,6 +49,10 @@ export function getLocalFilePath(resource: string) {
|
||||
return fromFileUrl(resource);
|
||||
}
|
||||
|
||||
if (isBundledAssetPath(resource)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return isAbsoluteLocalPath(resource) ? resource : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
BUILT_IN_WALLPAPERS,
|
||||
DEFAULT_WALLPAPER_PATH,
|
||||
DEFAULT_WALLPAPER_RELATIVE_PATH,
|
||||
getAvailableWallpapers,
|
||||
} from "./wallpapers";
|
||||
|
||||
describe("wallpapers", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps the curated wallpaper list and default path aligned", () => {
|
||||
expect(DEFAULT_WALLPAPER_PATH).toBe("/wallpapers/midnight-8.jpg");
|
||||
expect(DEFAULT_WALLPAPER_RELATIVE_PATH).toBe("wallpapers/midnight-8.jpg");
|
||||
expect(BUILT_IN_WALLPAPERS.at(0)?.publicPath).toBe(DEFAULT_WALLPAPER_PATH);
|
||||
expect(BUILT_IN_WALLPAPERS).toHaveLength(25);
|
||||
});
|
||||
|
||||
it("preserves the curated order when asset discovery returns extra files", async () => {
|
||||
vi.stubGlobal("window", {
|
||||
electronAPI: {
|
||||
listAssetDirectory: vi.fn(async () => ({
|
||||
success: true,
|
||||
files: [
|
||||
"wallpaper1.jpg",
|
||||
"energy-17.jpg",
|
||||
"midnight-8.jpg",
|
||||
"wallpaper4.jpg",
|
||||
"wispysky.mp4",
|
||||
"cityscape.jpg",
|
||||
"ipad-17-light.jpg",
|
||||
],
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(getAvailableWallpapers()).resolves.toEqual([
|
||||
BUILT_IN_WALLPAPERS[0],
|
||||
BUILT_IN_WALLPAPERS[2],
|
||||
BUILT_IN_WALLPAPERS[15],
|
||||
BUILT_IN_WALLPAPERS[16],
|
||||
BUILT_IN_WALLPAPERS[23],
|
||||
BUILT_IN_WALLPAPERS[24],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -9,158 +9,39 @@ const IMAGE_FILE_PATTERN = /\.(avif|gif|jpe?g|png|svg|webp)$/i;
|
||||
const VIDEO_FILE_PATTERN = /\.(avi|m4v|mkv|mov|mp4|webm)$/i;
|
||||
|
||||
export const BUILT_IN_WALLPAPERS: BuiltInWallpaper[] = [
|
||||
{
|
||||
id: "wallpaper-1",
|
||||
label: "Wallpaper 1",
|
||||
relativePath: "wallpapers/wallpaper1.jpg",
|
||||
publicPath: "/wallpapers/wallpaper1.jpg",
|
||||
},
|
||||
{
|
||||
id: "wallpaper-2",
|
||||
label: "Wallpaper 2",
|
||||
relativePath: "wallpapers/wallpaper2.jpg",
|
||||
publicPath: "/wallpapers/wallpaper2.jpg",
|
||||
},
|
||||
{
|
||||
id: "wallpaper-3",
|
||||
label: "Wallpaper 3",
|
||||
relativePath: "wallpapers/wallpaper3.jpg",
|
||||
publicPath: "/wallpapers/wallpaper3.jpg",
|
||||
},
|
||||
{
|
||||
id: "wallpaper-4",
|
||||
label: "Wallpaper 4",
|
||||
relativePath: "wallpapers/wallpaper4.jpg",
|
||||
publicPath: "/wallpapers/wallpaper4.jpg",
|
||||
},
|
||||
{
|
||||
id: "wallpaper-5",
|
||||
label: "Wallpaper 5",
|
||||
relativePath: "wallpapers/wallpaper5.jpg",
|
||||
publicPath: "/wallpapers/wallpaper5.jpg",
|
||||
},
|
||||
{
|
||||
id: "wallpaper-6",
|
||||
label: "Wallpaper 6",
|
||||
relativePath: "wallpapers/wallpaper6.jpg",
|
||||
publicPath: "/wallpapers/wallpaper6.jpg",
|
||||
},
|
||||
{
|
||||
id: "wallpaper-7",
|
||||
label: "Wallpaper 7",
|
||||
relativePath: "wallpapers/wallpaper7.jpg",
|
||||
publicPath: "/wallpapers/wallpaper7.jpg",
|
||||
},
|
||||
{
|
||||
id: "wallpaper-8",
|
||||
label: "Wallpaper 8",
|
||||
relativePath: "wallpapers/wallpaper8.jpg",
|
||||
publicPath: "/wallpapers/wallpaper8.jpg",
|
||||
},
|
||||
{
|
||||
id: "wallpaper-9",
|
||||
label: "Wallpaper 9",
|
||||
relativePath: "wallpapers/wallpaper9.jpg",
|
||||
publicPath: "/wallpapers/wallpaper9.jpg",
|
||||
},
|
||||
{
|
||||
id: "wallpaper-10",
|
||||
label: "Wallpaper 10",
|
||||
relativePath: "wallpapers/wallpaper10.jpg",
|
||||
publicPath: "/wallpapers/wallpaper10.jpg",
|
||||
},
|
||||
{
|
||||
id: "wallpaper-11",
|
||||
label: "Wallpaper 11",
|
||||
relativePath: "wallpapers/wallpaper11.jpg",
|
||||
publicPath: "/wallpapers/wallpaper11.jpg",
|
||||
},
|
||||
{
|
||||
id: "wallpaper-12",
|
||||
label: "Wallpaper 12",
|
||||
relativePath: "wallpapers/wallpaper12.jpg",
|
||||
publicPath: "/wallpapers/wallpaper12.jpg",
|
||||
},
|
||||
{
|
||||
id: "wallpaper-13",
|
||||
label: "Wallpaper 13",
|
||||
relativePath: "wallpapers/wallpaper13.jpg",
|
||||
publicPath: "/wallpapers/wallpaper13.jpg",
|
||||
},
|
||||
{
|
||||
id: "wallpaper-14",
|
||||
label: "Wallpaper 14",
|
||||
relativePath: "wallpapers/wallpaper14.jpg",
|
||||
publicPath: "/wallpapers/wallpaper14.jpg",
|
||||
},
|
||||
{
|
||||
id: "wallpaper-15",
|
||||
label: "Wallpaper 15",
|
||||
relativePath: "wallpapers/wallpaper15.jpg",
|
||||
publicPath: "/wallpapers/wallpaper15.jpg",
|
||||
},
|
||||
{
|
||||
id: "wallpaper-16",
|
||||
label: "Wallpaper 16",
|
||||
relativePath: "wallpapers/wallpaper16.jpg",
|
||||
publicPath: "/wallpapers/wallpaper16.jpg",
|
||||
},
|
||||
{
|
||||
id: "wallpaper-17",
|
||||
label: "Wallpaper 17",
|
||||
relativePath: "wallpapers/wallpaper17.jpg",
|
||||
publicPath: "/wallpapers/wallpaper17.jpg",
|
||||
},
|
||||
{
|
||||
id: "wallpaper-18",
|
||||
label: "Wallpaper 18",
|
||||
relativePath: "wallpapers/wallpaper18.jpg",
|
||||
publicPath: "/wallpapers/wallpaper18.jpg",
|
||||
},
|
||||
{
|
||||
id: "cityscape",
|
||||
label: "Cityscape",
|
||||
relativePath: "wallpapers/cityscape.jpg",
|
||||
publicPath: "/wallpapers/cityscape.jpg",
|
||||
},
|
||||
{
|
||||
id: "farmvalley",
|
||||
label: "Farm Valley",
|
||||
relativePath: "wallpapers/farmvalley.jpg",
|
||||
publicPath: "/wallpapers/farmvalley.jpg",
|
||||
},
|
||||
{
|
||||
id: "levels",
|
||||
label: "Levels",
|
||||
relativePath: "wallpapers/levels.jpg",
|
||||
publicPath: "/wallpapers/levels.jpg",
|
||||
},
|
||||
{
|
||||
id: "mountaintrees",
|
||||
label: "Mountain Trees",
|
||||
relativePath: "wallpapers/mountaintrees.jpg",
|
||||
publicPath: "/wallpapers/mountaintrees.jpg",
|
||||
},
|
||||
{
|
||||
id: "luisdelrio",
|
||||
label: "Luis Del Rio",
|
||||
relativePath: "wallpapers/luisdelrio.jpg",
|
||||
publicPath: "/wallpapers/luisdelrio.jpg",
|
||||
},
|
||||
{
|
||||
id: "wispysky",
|
||||
label: "Wispy Sky",
|
||||
relativePath: "wallpapers/wispysky.mp4",
|
||||
publicPath: "/wallpapers/wispysky.mp4",
|
||||
},
|
||||
createWallpaperEntry("midnight-8.jpg", "Midnight 8"),
|
||||
createWallpaperEntry("ipad-17-dark.jpg", "iPad 17 Dark"),
|
||||
createWallpaperEntry("ipad-17-light.jpg", "iPad 17 Light"),
|
||||
createWallpaperEntry("sequoia-blue.jpg", "Sequoia Blue"),
|
||||
createWallpaperEntry("sequoia-blue-orange.jpg", "Sequoia Blue Orange"),
|
||||
createWallpaperEntry("ventura.jpg", "Ventura"),
|
||||
createWallpaperEntry("tahoe-light.jpg", "Tahoe Light"),
|
||||
createWallpaperEntry("tahoe-dark.jpg", "Tahoe Dark"),
|
||||
createWallpaperEntry("sonoma-clouds.jpg", "Sonoma Clouds"),
|
||||
createWallpaperEntry("sonoma-light.jpg", "Sonoma Light"),
|
||||
createWallpaperEntry("sonoma-dark.jpg", "Sonoma Dark"),
|
||||
createWallpaperEntry("glassmorphism-3.jpg", "Glassmorphism 3"),
|
||||
createWallpaperEntry("glassmorphism-4.jpg", "Glassmorphism 4"),
|
||||
createWallpaperEntry("energy-19.jpg", "Energy 19"),
|
||||
createWallpaperEntry("wallpaper3.jpg", "Wallpaper 3"),
|
||||
createWallpaperEntry("wallpaper4.jpg", "Wallpaper 4"),
|
||||
createWallpaperEntry("cityscape.jpg", "Cityscape"),
|
||||
createWallpaperEntry("levels.jpg", "Levels"),
|
||||
createWallpaperEntry("wallpaper10.jpg", "Wallpaper 10"),
|
||||
createWallpaperEntry("ventura-dark.jpg", "Ventura Dark"),
|
||||
createWallpaperEntry("sonoma-evening.jpg", "Sonoma Evening"),
|
||||
createWallpaperEntry("sonoma-horizon.jpg", "Sonoma Horizon"),
|
||||
createWallpaperEntry("iridescent-9.jpg", "Iridescent 9"),
|
||||
createWallpaperEntry("energy-17.jpg", "Energy 17"),
|
||||
createWallpaperEntry("wispysky.mp4", "Wispy Sky"),
|
||||
];
|
||||
|
||||
export const WALLPAPER_PATHS = BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.publicPath);
|
||||
export const WALLPAPER_RELATIVE_PATHS = BUILT_IN_WALLPAPERS.map(
|
||||
(wallpaper) => wallpaper.relativePath,
|
||||
);
|
||||
export const DEFAULT_WALLPAPER_PATH = "/wallpapers/wallpaper2.jpg";
|
||||
export const DEFAULT_WALLPAPER_RELATIVE_PATH = "wallpapers/wallpaper2.jpg";
|
||||
export const DEFAULT_WALLPAPER_PATH = "/wallpapers/midnight-8.jpg";
|
||||
export const DEFAULT_WALLPAPER_RELATIVE_PATH = "wallpapers/midnight-8.jpg";
|
||||
|
||||
export function isVideoWallpaperSource(value: string): boolean {
|
||||
if (!value) {
|
||||
@@ -192,22 +73,16 @@ function toWallpaperLabel(fileName: string) {
|
||||
.replace(/\b\w/g, (match) => match.toUpperCase());
|
||||
}
|
||||
|
||||
function createWallpaperEntry(fileName: string): BuiltInWallpaper {
|
||||
function createWallpaperEntry(fileName: string, label = toWallpaperLabel(fileName)): BuiltInWallpaper {
|
||||
const encodedFileName = encodeURIComponent(fileName);
|
||||
return {
|
||||
id: toWallpaperId(fileName) || `wallpaper-${encodedFileName.toLowerCase()}`,
|
||||
label: toWallpaperLabel(fileName),
|
||||
label,
|
||||
relativePath: `wallpapers/${fileName}`,
|
||||
publicPath: `/wallpapers/${encodedFileName}`,
|
||||
};
|
||||
}
|
||||
|
||||
function sortWallpaperFiles(fileNames: string[]) {
|
||||
return [...fileNames].sort(
|
||||
new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAvailableWallpapers(): Promise<BuiltInWallpaper[]> {
|
||||
const fallbackWallpapers = BUILT_IN_WALLPAPERS;
|
||||
|
||||
@@ -221,18 +96,22 @@ export async function getAvailableWallpapers(): Promise<BuiltInWallpaper[]> {
|
||||
return fallbackWallpapers;
|
||||
}
|
||||
|
||||
const discoveredFiles = sortWallpaperFiles(
|
||||
const discoveredFiles = new Set(
|
||||
result.files.filter(
|
||||
(fileName) =>
|
||||
IMAGE_FILE_PATTERN.test(fileName) || VIDEO_FILE_PATTERN.test(fileName),
|
||||
),
|
||||
);
|
||||
|
||||
if (discoveredFiles.length === 0) {
|
||||
if (discoveredFiles.size === 0) {
|
||||
return fallbackWallpapers;
|
||||
}
|
||||
|
||||
return discoveredFiles.map(createWallpaperEntry);
|
||||
const curatedWallpapers = fallbackWallpapers.filter((wallpaper) =>
|
||||
discoveredFiles.has(wallpaper.relativePath.replace(/^wallpapers\//, "")),
|
||||
);
|
||||
|
||||
return curatedWallpapers.length > 0 ? curatedWallpapers : fallbackWallpapers;
|
||||
} catch {
|
||||
return fallbackWallpapers;
|
||||
}
|
||||
|
||||