diff --git a/docs/Features/Native-Helper.md b/docs/Features/Native-Helper.md
index 402e8f876..69c81cc88 100644
--- a/docs/Features/Native-Helper.md
+++ b/docs/Features/Native-Helper.md
@@ -23,6 +23,8 @@ The browser client can discover the auth token automatically from `GET /startup-
- **Format selection** -- List available formats and choose quality/codec before downloading
- **File system operations** -- Write files, create directories, list/delete/rename, check existence
- **Folder picker** -- Native OS folder picker dialog (for Firefox project folder selection)
+- **Picked-folder grants** -- Folder picker and restore paths are registered as allowed roots so projects outside the default Documents folder can be opened and served through the helper
+- **Manual path fallback** -- If the helper cannot show a native folder picker on the current platform, the web app prompts for the project folder path instead
- **Firefox persistence** -- Enables full project save/load on Firefox via file system commands
- **External AI control** -- Local `POST /api/ai-tools` bridge for Claude Code, curl, and other local agents
- **System tray** -- On Windows, runs as a system tray app with auto-start and self-update support
@@ -242,6 +244,7 @@ cargo build --release
1. Check firewall allows localhost:9876
2. Ensure only one instance running
3. On Windows, try `--console` flag to see log output
+4. If Firefox reports the helper as disconnected after refresh, press Check connection; the web client now refreshes the helper startup token on every reconnect, times out stalled reconnects, and retries every few seconds after a previously connected session
---
diff --git a/docs/Features/Project-Persistence.md b/docs/Features/Project-Persistence.md
index 5cad56109..f1159d7ba 100644
--- a/docs/Features/Project-Persistence.md
+++ b/docs/Features/Project-Persistence.md
@@ -39,6 +39,7 @@ On first launch or when no project is open, the Welcome Overlay appears:
For Firefox users:
- The overlay checks if the Native Helper is running and connected
- If available, activates the native backend and shows "New Project" / "Open Existing" buttons (using the OS folder picker via Native Helper)
+- If the helper cannot show an OS folder picker on the current platform, MasterSelects falls back to a manual path prompt seeded with the helper's project root
- If unavailable or outdated, persistence is unavailable until the helper is installed and connected
### Select Project Folder
@@ -69,12 +70,15 @@ The project system supports two backends, selected automatically based on browse
### Native Helper Backend (Firefox)
- Uses a local Rust helper (`tools/native-helper`) communicating via WebSocket (port 9876) and HTTP (port 9877)
- OS folder picker via `NativeHelperClient.pickFolder()`
+- Manual project path fallback via `ProjectFileService` when the helper reports that no native picker is available
+- User-picked project paths are granted to the helper at runtime, so external drives and non-default project folders remain readable through both WebSocket and HTTP file routes
- File I/O via `NativeHelperClient.writeFile()` / `readFileText()` / `writeFileBinary()` plus `createDir()`, `deleteFile()`, `rename()`, `exists()`, `listDir()`, and `pickFolder()`
- Project files are written through the helper's path-based storage layer; the browser never needs a `FileSystemDirectoryHandle`
- Last project path stored in `localStorage` key `ms-native-last-project-path`
- No permission prompts needed -- the Native Helper has full filesystem access
- Project listing: `NativeProjectCoreService.listProjects()` scans the project root for directories containing `project.json`
- The default project root comes from the helper (`Documents/MasterSelects` when available, otherwise `Home/MasterSelects`, or `MASTERSELECTS_PROJECT_ROOT` when set to an absolute path)
+- On Firefox refresh, `ProjectFileService.restoreLastProject()` now activates the Native backend before attempting restore, so it no longer depends on the Welcome Overlay running first
### Backend Switching
The `ProjectFileService` facade routes all calls to the active backend:
@@ -401,7 +405,7 @@ Temporary camera `NO KF` live offsets are intentionally not saved. They only aff
### Restore Last Project
On app load, attempts to restore the previously opened project:
- **FSA**: Retrieves `lastProject` handle from IndexedDB, checks permission
-- **Native**: Reads path from `localStorage` key `ms-native-last-project-path`
+- **Native**: Activates the helper backend, reconnects to the helper with a bounded timeout, grants the stored project path to the helper, then reads path from `localStorage` key `ms-native-last-project-path`
- If permission is needed, shows a "Grant Access" prompt
- If the project folder no longer exists, the saved path is cleared and the user must choose/open another project
diff --git a/src/changelog-data.json b/src/changelog-data.json
index c187b8637..3b66d629b 100644
--- a/src/changelog-data.json
+++ b/src/changelog-data.json
@@ -1,4 +1,14 @@
[
+ {
+ "date": "2026-05-01",
+ "type": "fix",
+ "title": "Firefox Native Project Restore Is Fast Again",
+ "description": "Native Helper project open now connects quickly in Firefox, restores project media without eager-loading every Raw asset, keeps relinked GLB and PLY sequences available after refresh, and lets restored media import into the timeline again.",
+ "section": "Native Helper / Project Persistence",
+ "commits": [
+ "c67aacfb"
+ ]
+ },
{
"date": "2026-05-01",
"type": "new",
diff --git a/src/components/common/NativeHelperStatus.tsx b/src/components/common/NativeHelperStatus.tsx
index 5548dbb7f..74ae33f0b 100644
--- a/src/components/common/NativeHelperStatus.tsx
+++ b/src/components/common/NativeHelperStatus.tsx
@@ -8,6 +8,7 @@ import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react
import { NativeHelperClient, isNativeHelperAvailable } from '../../services/nativeHelper';
import type { SystemInfo, ConnectionStatus } from '../../services/nativeHelper';
import {
+ compareNativeHelperVersions,
fetchLatestPublishedNativeHelperRelease,
NATIVE_HELPER_RELEASES_URL,
NATIVE_HELPER_TARGET_VERSION,
@@ -146,6 +147,7 @@ export function NativeHelperStatus({ variant = 'toolbar' }: NativeHelperStatusPr
const {
turboModeEnabled,
+ nativeHelperPort,
nativeDecodeEnabled,
setNativeDecodeEnabled,
setNativeHelperConnected,
@@ -161,6 +163,7 @@ export function NativeHelperStatus({ variant = 'toolbar' }: NativeHelperStatusPr
}
try {
+ NativeHelperClient.configure({ port: nativeHelperPort });
const available = await isNativeHelperAvailable();
setStatus(available ? 'connected' : 'disconnected');
setNativeHelperConnected(available);
@@ -168,7 +171,7 @@ export function NativeHelperStatus({ variant = 'toolbar' }: NativeHelperStatusPr
setStatus('disconnected');
setNativeHelperConnected(false);
}
- }, [helperEnabled, setNativeHelperConnected]);
+ }, [helperEnabled, nativeHelperPort, setNativeHelperConnected]);
useEffect(() => {
if (nativeDecodeEnabled) {
@@ -270,7 +273,17 @@ function NativeHelperDialog({
useEffect(() => {
if (status === 'connected') {
- NativeHelperClient.getInfo().then(setInfo).catch(() => setInfo(null));
+ let cancelled = false;
+ NativeHelperClient.getInfo(3000)
+ .then((nextInfo) => {
+ if (!cancelled) setInfo(nextInfo);
+ })
+ .catch(() => {
+ if (!cancelled) setInfo(null);
+ });
+ return () => {
+ cancelled = true;
+ };
}
}, [status]);
@@ -329,13 +342,17 @@ function NativeHelperDialog({
const downloadLink = publishedRelease?.url || ((platform !== 'unknown' && DOWNLOAD_LINKS[platform]) || NATIVE_HELPER_RELEASES);
const connectedVersion = helperInfo?.version ?? null;
const publishedVersion = publishedRelease?.version ?? null;
- const expectedVersionInstalled = connectedVersion === NATIVE_HELPER_VERSION;
+ const versionKnown = connectedVersion !== null;
+ const helperVersionCompare = compareNativeHelperVersions(connectedVersion, NATIVE_HELPER_VERSION);
+ const expectedVersionInstalled = versionKnown && helperVersionCompare >= 0;
+ const helperNeedsUpdate = isConnected && versionKnown && helperVersionCompare < 0;
+ const publishedMatchesTarget = compareNativeHelperVersions(publishedVersion, NATIVE_HELPER_VERSION) >= 0;
const statusTone = isConnected ? 'connected' : helperEnabled ? 'offline' : 'disabled';
const statusLabel = isConnected ? 'Connected' : helperEnabled ? 'Not running' : 'Disabled';
const capabilityPills: Array<{ label: string; tone: PillTone }> = isConnected && helperInfo
? [
- { label: connectedVersion ? `Installed v${connectedVersion}` : 'Connected', tone: expectedVersionInstalled ? 'good' : 'warn' },
+ { label: connectedVersion ? `Installed v${connectedVersion}` : 'Connected', tone: helperNeedsUpdate ? 'warn' : 'good' },
{ label: helperInfo.ytdlp_available ? 'Downloads ready' : 'yt-dlp missing', tone: helperInfo.ytdlp_available ? 'good' : 'warn' },
{ label: helperInfo.fs_commands ? 'Projects ready' : 'Projects unavailable', tone: helperInfo.fs_commands ? 'good' : 'warn' },
{ label: helperInfo.ai_bridge ? 'AI bridge ready' : 'AI bridge unavailable', tone: helperInfo.ai_bridge ? 'good' : 'warn' },
@@ -408,27 +425,29 @@ function NativeHelperDialog({
{isConnected ? 'Connected session' : 'Published release'}
- {isConnected && connectedVersion
- ? `Helper v${connectedVersion}`
+ {isConnected
+ ? (connectedVersion ? `Helper v${connectedVersion}` : 'Helper connected')
: publishedVersion
? `GitHub release v${publishedVersion}`
: 'Native Helper releases'}
-
- {isConnected ? (expectedVersionInstalled ? 'Up to date' : 'Update available') : (helperEnabled ? 'Waiting for helper' : 'Helper disabled')}
+
+ {isConnected
+ ? (versionKnown ? (expectedVersionInstalled ? 'Up to date' : 'Update available') : 'Connected')
+ : (helperEnabled ? 'Waiting for helper' : 'Helper disabled')}
{isConnected
? (
- publishedVersion && publishedVersion !== NATIVE_HELPER_VERSION
+ publishedVersion && !publishedMatchesTarget
? `The helper is reachable from MasterSelects. GitHub still only publishes v${publishedVersion}, while this app build already targets v${NATIVE_HELPER_VERSION}.`
: 'The helper is reachable from MasterSelects on this machine.'
)
: (
- publishedVersion && publishedVersion !== NATIVE_HELPER_VERSION
+ publishedVersion && !publishedMatchesTarget
? `GitHub currently only has v${publishedVersion} published. MasterSelects already targets helper v${NATIVE_HELPER_VERSION}, but that release is not public yet.`
: 'Install the current helper build and keep it running in the background.'
)}
@@ -441,11 +460,11 @@ function NativeHelperDialog({
))}
{publishedVersion && (
-
+
Public GitHub: v{publishedVersion}
)}
- {publishedVersion && publishedVersion !== NATIVE_HELPER_VERSION && (
+ {publishedVersion && !publishedMatchesTarget && (
App target: v{NATIVE_HELPER_VERSION}
diff --git a/src/components/common/RelinkDialog.tsx b/src/components/common/RelinkDialog.tsx
index 8de9337c3..b67b03dab 100644
--- a/src/components/common/RelinkDialog.tsx
+++ b/src/components/common/RelinkDialog.tsx
@@ -11,6 +11,7 @@ import {
applyRelinkMatch,
createRelinkCandidateMapFromHandles,
findRelinkMatch,
+ mediaNeedsRelink,
type RelinkCandidate,
type RelinkCandidateMap,
type RelinkMatch,
@@ -43,7 +44,7 @@ function isAbortError(error: unknown): boolean {
}
function getMissingFiles(files: MediaFile[]): MediaFile[] {
- return files.filter(f => !f.file);
+ return files.filter(mediaNeedsRelink);
}
function matchStatuses(
@@ -82,6 +83,8 @@ export function RelinkDialog({ onClose }: RelinkDialogProps) {
// Initialize file statuses and auto-scan Raw folder
useEffect(() => {
+ let cancelled = false;
+
const initializeStatuses = async () => {
const missingFiles = getMissingFiles(files);
const initialStatuses: FileStatus[] = missingFiles.map(f => ({
@@ -90,6 +93,7 @@ export function RelinkDialog({ onClose }: RelinkDialogProps) {
filePath: f.filePath,
status: 'missing' as const,
}));
+ if (cancelled) return;
setFileStatuses(initialStatuses);
// Auto-scan the project folder for missing files if project is open.
@@ -121,6 +125,7 @@ export function RelinkDialog({ onClose }: RelinkDialogProps) {
}
}
+ if (cancelled) return;
setFileStatuses(updatedStatuses);
if (searched.length > 0) {
setSearchedFolders(searched);
@@ -129,6 +134,9 @@ export function RelinkDialog({ onClose }: RelinkDialogProps) {
};
initializeStatuses();
+ return () => {
+ cancelled = true;
+ };
}, [files]);
// Scan a folder for missing files
@@ -165,7 +173,31 @@ export function RelinkDialog({ onClose }: RelinkDialogProps) {
// Handle browse button
const handleBrowse = useCallback(async () => {
+ if (projectFileService.activeBackend === 'native') {
+ setIsSearching(true);
+ try {
+ const result = await projectFileService.pickAndScanFolder('Search folder for missing media');
+ if (!result) {
+ return;
+ }
+
+ const candidates = await createRelinkCandidateMapFromHandles(result.files.values());
+ setFileStatuses(prev => matchStatuses(prev, files, candidates));
+ setSearchedFolders(prev => [...prev, result.name]);
+ } catch (e) {
+ log.error('Native browse error', e);
+ } finally {
+ setIsSearching(false);
+ }
+ return;
+ }
+
try {
+ if (typeof (window as RelinkPickerWindow).showDirectoryPicker !== 'function') {
+ log.warn('Directory picker is not available in this browser');
+ return;
+ }
+
const dirHandle = await (window as RelinkPickerWindow).showDirectoryPicker({
mode: 'read',
startIn: 'videos',
@@ -179,7 +211,7 @@ export function RelinkDialog({ onClose }: RelinkDialogProps) {
log.error('Browse error', e);
}
}
- }, [scanFolder]);
+ }, [files, scanFolder]);
// Handle picking individual file - allows multiple selection to relink several at once
const handlePickFile = useCallback(async (fileStatus: FileStatus) => {
diff --git a/src/components/common/WelcomeOverlay.tsx b/src/components/common/WelcomeOverlay.tsx
index 9ed9addfe..bd8b47ea0 100644
--- a/src/components/common/WelcomeOverlay.tsx
+++ b/src/components/common/WelcomeOverlay.tsx
@@ -12,6 +12,7 @@ import { openExistingProject } from '../../services/projectSync';
import { NativeHelperClient } from '../../services/nativeHelper/NativeHelperClient';
import { loadProjectToStores } from '../../services/project/projectLoad';
import { syncStoresToProject } from '../../services/project/projectSave';
+import { useSettingsStore } from '../../stores/settingsStore';
type NativeStatus = 'checking' | 'available' | 'outdated' | 'unavailable';
type DirectoryPickerWindow = Window & typeof globalThis & {
@@ -88,7 +89,8 @@ export function WelcomeOverlay({ onComplete, noFadeOnClose = false }: WelcomeOve
// Native Helper state (for Firefox project persistence)
const [nativeStatus, setNativeStatus] = useState('checking');
- const [nativeProjectRoot, setNativeProjectRoot] = useState('');
+ const nativeHelperPort = useSettingsStore((state) => state.nativeHelperPort);
+ const setNativeHelperConnected = useSettingsStore((state) => state.setNativeHelperConnected);
const isSupported = isFileSystemAccessSupported();
const browser = useMemo(() => detectBrowser(), []);
@@ -105,13 +107,19 @@ export function WelcomeOverlay({ onComplete, noFadeOnClose = false }: WelcomeOve
async function checkNativeHelper() {
try {
+ NativeHelperClient.configure({ port: nativeHelperPort });
+
// Try to connect if not already
if (!NativeHelperClient.isConnected()) {
- await NativeHelperClient.connect();
+ const connected = await NativeHelperClient.connect();
+ setNativeHelperConnected(connected);
}
if (!NativeHelperClient.isConnected()) {
- if (!cancelled) setNativeStatus('unavailable');
+ if (!cancelled) {
+ setNativeStatus('unavailable');
+ setNativeHelperConnected(false);
+ }
return;
}
@@ -129,20 +137,18 @@ export function WelcomeOverlay({ onComplete, noFadeOnClose = false }: WelcomeOve
// Activate native backend
projectFileService.activateNativeBackend();
- // Get project root path (used as default for folder picker)
- const root = await NativeHelperClient.getProjectRoot();
- if (!cancelled && root) {
- setNativeProjectRoot(root.replace(/\\/g, '/'));
- }
} catch (e) {
log.warn('Native helper check failed', e);
- if (!cancelled) setNativeStatus('unavailable');
+ if (!cancelled) {
+ setNativeStatus('unavailable');
+ setNativeHelperConnected(false);
+ }
}
}
checkNativeHelper();
return () => { cancelled = true; };
- }, [needsNativeHelper]);
+ }, [needsNativeHelper, nativeHelperPort, setNativeHelperConnected]);
// Typewriter effect
useEffect(() => {
@@ -317,33 +323,14 @@ export function WelcomeOverlay({ onComplete, noFadeOnClose = false }: WelcomeOve
}
}, [isSelecting, isClosing, onComplete, noFadeOnClose]);
- // Native Helper: Create new project (opens OS folder picker, like Chrome)
+ // Native Helper: Create new project through the ProjectFileService native backend.
const handleNativeNewProject = useCallback(async () => {
if (isSelecting || isClosing) return;
setIsSelecting(true);
setError(null);
try {
- // Open OS folder picker — same UX as Chrome's showDirectoryPicker()
- const folderPath = await NativeHelperClient.pickFolder(
- 'Choose where to save your project',
- nativeProjectRoot || undefined,
- );
-
- if (!folderPath) {
- // User cancelled
- return;
- }
-
- const nativeCore = projectFileService.getNativeCoreService();
- if (!nativeCore) {
- setError('Native backend not active.');
- return;
- }
-
- // Create "Untitled" project in the selected folder (matches Chrome behavior)
- const normalizedPath = folderPath.replace(/\\/g, '/');
- const success = await nativeCore.createProjectAtPath(normalizedPath, 'Untitled');
+ const success = await projectFileService.createProject('Untitled');
if (success) {
setSelectedFolder('Untitled');
@@ -361,28 +348,16 @@ export function WelcomeOverlay({ onComplete, noFadeOnClose = false }: WelcomeOve
} finally {
setIsSelecting(false);
}
- }, [isSelecting, isClosing, nativeProjectRoot, onComplete, noFadeOnClose]);
+ }, [isSelecting, isClosing, onComplete, noFadeOnClose]);
- // Native Helper: Open existing project (opens OS folder picker, like Chrome)
+ // Native Helper: Open existing project through the ProjectFileService native backend.
const handleNativeOpenProject = useCallback(async () => {
if (isSelecting || isClosing) return;
setIsSelecting(true);
setError(null);
try {
- // Open OS folder picker — user selects existing project folder
- const folderPath = await NativeHelperClient.pickFolder(
- 'Select an existing project folder',
- nativeProjectRoot || undefined,
- );
-
- if (!folderPath) {
- // User cancelled
- return;
- }
-
- const normalizedPath = folderPath.replace(/\\/g, '/');
- const success = await projectFileService.loadProject(normalizedPath);
+ const success = await projectFileService.openProject();
if (success) {
const projectData = projectFileService.getProjectData();
@@ -400,7 +375,7 @@ export function WelcomeOverlay({ onComplete, noFadeOnClose = false }: WelcomeOve
} finally {
setIsSelecting(false);
}
- }, [isSelecting, isClosing, nativeProjectRoot, onComplete, noFadeOnClose]);
+ }, [isSelecting, isClosing, onComplete, noFadeOnClose]);
const handleContinue = useCallback(() => {
if (isClosing) return;
diff --git a/src/components/common/settings/NativeHelperSettings.tsx b/src/components/common/settings/NativeHelperSettings.tsx
index 6646a210b..eb4b7de02 100644
--- a/src/components/common/settings/NativeHelperSettings.tsx
+++ b/src/components/common/settings/NativeHelperSettings.tsx
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react
import { NativeHelperClient, isNativeHelperAvailable } from '../../../services/nativeHelper';
import type { SystemInfo, ConnectionStatus } from '../../../services/nativeHelper';
import {
+ compareNativeHelperVersions,
fetchLatestPublishedNativeHelperRelease,
NATIVE_HELPER_RELEASES_URL,
NATIVE_HELPER_TARGET_VERSION,
@@ -128,6 +129,7 @@ export function NativeHelperSettings() {
}
setChecking(true);
try {
+ NativeHelperClient.configure({ port: nativeHelperPort });
const available = await isNativeHelperAvailable();
setStatus(available ? 'connected' : 'disconnected');
setNativeHelperConnected(available);
@@ -136,7 +138,7 @@ export function NativeHelperSettings() {
setNativeHelperConnected(false);
}
setChecking(false);
- }, [helperEnabled, setNativeHelperConnected]);
+ }, [helperEnabled, nativeHelperPort, setNativeHelperConnected]);
useEffect(() => {
queueMicrotask(() => void checkConnection());
@@ -149,7 +151,17 @@ export function NativeHelperSettings() {
useEffect(() => {
if (status === 'connected') {
- NativeHelperClient.getInfo().then(setInfo).catch(() => setInfo(null));
+ let cancelled = false;
+ NativeHelperClient.getInfo(3000)
+ .then((nextInfo) => {
+ if (!cancelled) setInfo(nextInfo);
+ })
+ .catch(() => {
+ if (!cancelled) setInfo(null);
+ });
+ return () => {
+ cancelled = true;
+ };
}
}, [status]);
@@ -164,7 +176,11 @@ export function NativeHelperSettings() {
const isConnected = status === 'connected';
const connectedVersion = helperInfo?.version ?? null;
const publishedVersion = publishedRelease?.version ?? null;
- const expectedVersionInstalled = connectedVersion === NATIVE_HELPER_VERSION;
+ const versionKnown = connectedVersion !== null;
+ const helperVersionCompare = compareNativeHelperVersions(connectedVersion, NATIVE_HELPER_VERSION);
+ const expectedVersionInstalled = versionKnown && helperVersionCompare >= 0;
+ const helperNeedsUpdate = isConnected && versionKnown && helperVersionCompare < 0;
+ const publishedMatchesTarget = compareNativeHelperVersions(publishedVersion, NATIVE_HELPER_VERSION) >= 0;
const downloadLink = publishedRelease?.url || NATIVE_HELPER_RELEASES_URL;
const capabilities: Array<{ label: string; tone: CapTone }> = isConnected && helperInfo
@@ -235,20 +251,20 @@ export function NativeHelperSettings() {
- {isConnected && connectedVersion
- ? `Helper v${connectedVersion}`
+ {isConnected
+ ? (connectedVersion ? `Helper v${connectedVersion}` : 'Helper connected')
: publishedVersion
? `GitHub release v${publishedVersion}`
: 'Native Helper'}
-
+
{isConnected
- ? (expectedVersionInstalled ? 'Up to date' : 'Update available')
+ ? (versionKnown ? (expectedVersionInstalled ? 'Up to date' : 'Update available') : 'Connected')
: (helperEnabled ? 'Waiting...' : 'Disabled')}
- {isConnected && publishedVersion && publishedVersion !== NATIVE_HELPER_VERSION && (
+ {isConnected && publishedVersion && !publishedMatchesTarget && (
GitHub publishes v{publishedVersion}, app targets v{NATIVE_HELPER_VERSION}.
@@ -265,10 +281,10 @@ export function NativeHelperSettings() {
{/* Version pills */}
{publishedVersion && (
-
+
Public GitHub: v{publishedVersion}
- {publishedVersion !== NATIVE_HELPER_VERSION && (
+ {!publishedMatchesTarget && (
App target: v{NATIVE_HELPER_VERSION}
)}
diff --git a/src/components/panels/MediaPanel.tsx b/src/components/panels/MediaPanel.tsx
index ee4305f21..2ce99bb4f 100644
--- a/src/components/panels/MediaPanel.tsx
+++ b/src/components/panels/MediaPanel.tsx
@@ -18,6 +18,7 @@ import { useTimelineStore } from '../../stores/timeline';
import { useDockStore } from '../../stores/dockStore';
import { useContextMenuPosition } from '../../hooks/useContextMenuPosition';
import { RelinkDialog } from '../common/RelinkDialog';
+import { mediaNeedsRelink } from '../../services/project/relinkMedia';
import {
clearExternalDragPayload,
dispatchExternalDragBridgeEvent,
@@ -1199,7 +1200,7 @@ export function MediaPanel() {
} else if ((item.type === 'video' || item.type === 'image') && 'file' in item && (item as MediaFile).file) {
// Open in source monitor
useMediaStore.getState().setSourceMonitorFile(item.id);
- } else if ('file' in item && !item.file) {
+ } else if ('file' in item && mediaNeedsRelink(item as MediaFile)) {
// Media file needs reload - request permission
const success = await reloadFile(item.id);
if (success) {
@@ -1567,8 +1568,8 @@ export function MediaPanel() {
// Handle media file drag
const mediaFile = item as MediaFile;
- if (!mediaFile.file || mediaFile.isImporting) {
- // File not available or still importing - only allow internal move
+ if (mediaFile.isImporting || mediaNeedsRelink(mediaFile)) {
+ // File still importing or truly unresolved - only allow internal move
e.dataTransfer.effectAllowed = 'move';
if (e.currentTarget instanceof HTMLElement) {
e.dataTransfer.setDragImage(e.currentTarget, 10, 10);
@@ -1577,9 +1578,11 @@ export function MediaPanel() {
}
// Set the media file ID so Timeline can look it up
+ const fileName = mediaFile.file?.name ?? mediaFile.name;
const isAudioOnly =
- mediaFile.file.type.startsWith('audio/') ||
- /\.(mp3|wav|ogg|aac|m4a|flac|wma|aiff|alac|opus)$/i.test(mediaFile.file.name);
+ mediaFile.type === 'audio' ||
+ mediaFile.file?.type.startsWith('audio/') ||
+ /\.(mp3|wav|ogg|aac|m4a|flac|wma|aiff|alac|opus)$/i.test(fileName);
setExternalDragPayload({
kind: 'media-file',
id: mediaFile.id,
@@ -1970,7 +1973,7 @@ export function MediaPanel() {
const isRenaming = renamingId === item.id;
const isExpanded = isFolder && expandedFolderIds.includes(item.id);
const isMediaFile = isImportedMediaFileItem(item);
- const hasFile = isMediaFile && !!item.file;
+ const needsRelink = isMediaFile && mediaNeedsRelink(item);
const isImporting = isMediaFile && !!item.isImporting;
const isDragTarget = isFolder && dragOverFolderId === item.id;
const isBeingDragged = internalDragId === item.id;
@@ -1980,7 +1983,7 @@ export function MediaPanel() {
handleDragStart(e, item)}
onDragEnd={handleDragEnd}
@@ -3557,7 +3560,7 @@ export function MediaPanel() {
data-item-id={item.id}
data-board-group-key={getMediaBoardOrderKey(placement.groupId)}
data-media-panel-anim-id={item.id}
- className={`media-board-node ${isSelected ? 'selected' : ''} ${isMediaFile && !mediaFile?.file ? 'no-file' : ''} ${importProgress !== null ? 'importing' : ''} ${isTextItem ? 'text' : ''} ${placement.isDraggingPreview ? 'drag-source-preview' : ''}`}
+ className={`media-board-node ${isSelected ? 'selected' : ''} ${mediaFile && mediaNeedsRelink(mediaFile) ? 'no-file' : ''} ${importProgress !== null ? 'importing' : ''} ${isTextItem ? 'text' : ''} ${placement.isDraggingPreview ? 'drag-source-preview' : ''}`}
style={{
left: layout.x,
top: layout.y,
@@ -3790,9 +3793,11 @@ export function MediaPanel() {
gridBreadcrumb.push(...path);
}
- // Check if any files need reload (lost permission after refresh)
- const filesNeedReload = files.some(f => !f.file);
- const filesNeedReloadCount = files.filter(f => !f.file).length;
+ // Check if any files need relinking (lost permission after refresh).
+ // Native-helper projects can be linked by project/absolute paths without
+ // eagerly materializing browser File objects for every media item.
+ const filesNeedReload = files.some(mediaNeedsRelink);
+ const filesNeedReloadCount = files.filter(mediaNeedsRelink).length;
// Relink dialog state
const [showRelinkDialog, setShowRelinkDialog] = useState(false);
diff --git a/src/components/timeline/hooks/useExternalDrop.ts b/src/components/timeline/hooks/useExternalDrop.ts
index 5bc277ba3..ad5dbba8f 100644
--- a/src/components/timeline/hooks/useExternalDrop.ts
+++ b/src/components/timeline/hooks/useExternalDrop.ts
@@ -23,7 +23,8 @@ import {
} from '../utils/externalDragSession';
import type { ExternalDragState } from '../types';
import type { TimelineTrack, TimelineClip } from '../../../types';
-import type { Composition } from '../../../stores/mediaStore';
+import type { Composition, MediaFile } from '../../../stores/mediaStore';
+import { NativeHelperClient } from '../../../services/nativeHelper/NativeHelperClient';
import { Logger } from '../../../services/logger';
const log = Logger.create('useExternalDrop');
@@ -39,6 +40,109 @@ function setDroppedFilePath(file: File, filePath?: string): void {
}
}
+const CLIP_TYPED_MEDIA_TYPES = new Set
(['gaussian-splat', 'lottie', 'rive', 'model']);
+
+function getTimelineMediaTypeOverride(mediaFile: MediaFile): string | undefined {
+ return CLIP_TYPED_MEDIA_TYPES.has(mediaFile.type) ? mediaFile.type : undefined;
+}
+
+function getPlaceholderMimeType(mediaFile: MediaFile): string {
+ const name = mediaFile.name.toLowerCase();
+
+ if (mediaFile.type === 'model') {
+ if (name.endsWith('.glb')) return 'model/gltf-binary';
+ if (name.endsWith('.gltf')) return 'model/gltf+json';
+ if (name.endsWith('.obj')) return 'model/obj';
+ }
+
+ if (mediaFile.type === 'gaussian-splat') {
+ if (name.endsWith('.ply')) return 'application/octet-stream';
+ if (name.endsWith('.spz')) return 'application/octet-stream';
+ }
+
+ return '';
+}
+
+function createPlaceholderFileForMedia(mediaFile: MediaFile): File {
+ const file = new File([], mediaFile.name, { type: getPlaceholderMimeType(mediaFile) });
+ setDroppedFilePath(file, mediaFile.absolutePath ?? mediaFile.filePath);
+ return file;
+}
+
+function mediaFileHasLazy3DSource(mediaFile: MediaFile): boolean {
+ if (mediaFile.file || mediaFile.url || mediaFile.absolutePath || mediaFile.projectPath) {
+ return true;
+ }
+
+ if (mediaFile.modelSequence?.frames.some((frame) =>
+ Boolean(frame.file || frame.modelUrl || frame.absolutePath || frame.projectPath || frame.sourcePath)
+ )) {
+ return true;
+ }
+
+ return Boolean(mediaFile.gaussianSplatSequence?.frames.some((frame) =>
+ Boolean(frame.file || frame.splatUrl || frame.absolutePath || frame.projectPath || frame.sourcePath)
+ ));
+}
+
+function isAudioOnlyMediaFile(mediaFile: MediaFile, file?: File): boolean {
+ return mediaFile.type === 'audio' || Boolean(file && isAudioFile(file));
+}
+
+async function resolveMediaFileForTimeline(mediaFile: MediaFile): Promise {
+ if (mediaFile.file) {
+ return mediaFile.file;
+ }
+
+ if (mediaFile.type === 'model' || mediaFile.type === 'gaussian-splat') {
+ return mediaFileHasLazy3DSource(mediaFile) ? createPlaceholderFileForMedia(mediaFile) : null;
+ }
+
+ const nativeReferenceUrl = NativeHelperClient.parseFileReferenceUrl(mediaFile.url)
+ ? mediaFile.url
+ : mediaFile.absolutePath
+ ? NativeHelperClient.getFileReferenceUrl(mediaFile.absolutePath)
+ : null;
+
+ if (!nativeReferenceUrl) {
+ return null;
+ }
+
+ try {
+ const file = await NativeHelperClient.getReferencedFile(nativeReferenceUrl, mediaFile.name);
+ if (!file) {
+ return null;
+ }
+
+ const referencedPath = NativeHelperClient.parseFileReferenceUrl(nativeReferenceUrl) ?? mediaFile.absolutePath;
+ setDroppedFilePath(file, referencedPath ?? undefined);
+ const url = URL.createObjectURL(file);
+
+ useMediaStore.setState((state) => ({
+ files: state.files.map((currentFile) =>
+ currentFile.id === mediaFile.id
+ ? {
+ ...currentFile,
+ file,
+ url,
+ hasFileHandle: true,
+ absolutePath: currentFile.absolutePath ?? referencedPath ?? undefined,
+ }
+ : currentFile
+ ),
+ }));
+
+ return file;
+ } catch (error) {
+ log.warn('Could not resolve restored media file for timeline drop', {
+ mediaFileId: mediaFile.id,
+ name: mediaFile.name,
+ error,
+ });
+ return null;
+ }
+}
+
interface UseExternalDropProps {
timelineRef: React.RefObject;
scrollX: number;
@@ -1111,8 +1215,8 @@ export function useExternalDrop({
if (mediaFileId) {
const mediaStore = useMediaStore.getState();
const mediaFile = mediaStore.files.find((f) => f.id === mediaFileId);
- if (mediaFile?.file) {
- const fileIsAudio = isAudioFile(mediaFile.file);
+ if (mediaFile) {
+ const fileIsAudio = isAudioOnlyMediaFile(mediaFile, mediaFile.file);
if (fileIsAudio && trackType === 'video') {
log.debug('Audio files can only be dropped on audio tracks');
return;
@@ -1217,12 +1321,17 @@ export function useExternalDrop({
if (mediaFileId) {
const mediaStore = useMediaStore.getState();
const mediaFile = mediaStore.files.find((f) => f.id === mediaFileId);
- if (mediaFile?.file) {
- // Pass mediaType override for formats that are intentionally clip-typed.
- const typeOverride = mediaFile.type === 'gaussian-splat' || mediaFile.type === 'lottie' || mediaFile.type === 'rive' || mediaFile.type === 'model'
- ? mediaFile.type
- : undefined;
- addClip(newTrackId, mediaFile.file, startTime, mediaFile.duration, mediaFileId, typeOverride);
+ if (mediaFile) {
+ const file = await resolveMediaFileForTimeline(mediaFile);
+ if (!file) {
+ log.warn('Could not add media panel item to new track because the file is not resolved', {
+ mediaFileId,
+ name: mediaFile.name,
+ });
+ return;
+ }
+
+ addClip(newTrackId, file, startTime, mediaFile.duration, mediaFileId, getTimelineMediaTypeOverride(mediaFile));
return;
}
}
@@ -1366,8 +1475,8 @@ export function useExternalDrop({
if (mediaFileId) {
const mediaStore = useMediaStore.getState();
const mediaFile = mediaStore.files.find((f) => f.id === mediaFileId);
- if (mediaFile?.file) {
- const fileIsAudio = isAudioFile(mediaFile.file);
+ if (mediaFile) {
+ const fileIsAudio = isAudioOnlyMediaFile(mediaFile, mediaFile.file);
// Audio-only files can only go on audio tracks
if (fileIsAudio && isVideoTrack) {
log.debug('Audio files can only be dropped on audio tracks');
@@ -1375,11 +1484,16 @@ export function useExternalDrop({
}
// Video+audio files are allowed on both track types
- // Pass mediaType override for formats that are intentionally clip-typed.
- const typeOverride = mediaFile.type === 'gaussian-splat' || mediaFile.type === 'lottie' || mediaFile.type === 'rive' || mediaFile.type === 'model'
- ? mediaFile.type
- : undefined;
- addClip(trackId, mediaFile.file, resolveDropStartTime(mediaFile.duration), mediaFile.duration, mediaFileId, typeOverride);
+ const file = await resolveMediaFileForTimeline(mediaFile);
+ if (!file) {
+ log.warn('Could not add media panel item to timeline because the file is not resolved', {
+ mediaFileId,
+ name: mediaFile.name,
+ });
+ return;
+ }
+
+ addClip(trackId, file, resolveDropStartTime(mediaFile.duration), mediaFile.duration, mediaFileId, getTimelineMediaTypeOverride(mediaFile));
return;
}
}
diff --git a/src/editorBoot.ts b/src/editorBoot.ts
index fc77d1835..b08841580 100644
--- a/src/editorBoot.ts
+++ b/src/editorBoot.ts
@@ -1,5 +1,35 @@
import { useTimelineStore } from './stores/timeline';
import { AI_TOOLS, executeAITool, getQuickTimelineSummary } from './services/aiTools';
+import { isFileSystemAccessSupported } from './services/fileSystemService';
+import { NativeHelperClient } from './services/nativeHelper/NativeHelperClient';
+import { useSettingsStore } from './stores/settingsStore';
+
+function warmNativeHelperForProjectBackend(): void {
+ if (typeof window === 'undefined' || isFileSystemAccessSupported()) {
+ return;
+ }
+
+ const {
+ turboModeEnabled,
+ nativeHelperPort,
+ setNativeHelperConnected,
+ } = useSettingsStore.getState();
+
+ if (!turboModeEnabled) {
+ return;
+ }
+
+ NativeHelperClient.configure({ port: nativeHelperPort });
+ NativeHelperClient.onStatusChange((status) => {
+ setNativeHelperConnected(status === 'connected');
+ });
+
+ void NativeHelperClient.connect()
+ .then((connected) => setNativeHelperConnected(connected))
+ .catch(() => setNativeHelperConnected(false));
+}
+
+warmNativeHelperForProjectBackend();
// Expose AI tools API for browser console, Claude skills, and external agents
// Only available in development mode to prevent production exposure
diff --git a/src/engine/native3d/assets/ModelRuntimeCache.ts b/src/engine/native3d/assets/ModelRuntimeCache.ts
index 7b28cfd78..a517e2a15 100644
--- a/src/engine/native3d/assets/ModelRuntimeCache.ts
+++ b/src/engine/native3d/assets/ModelRuntimeCache.ts
@@ -1,4 +1,5 @@
import { Logger } from '../../../services/logger';
+import { NativeHelperClient } from '../../../services/nativeHelper/NativeHelperClient';
const log = Logger.create('ModelRuntimeCache');
@@ -9,6 +10,54 @@ const GLB_BIN_CHUNK = 0x004e4942;
type ModelColor = readonly [number, number, number, number];
+type NativeFileReferenceClient = {
+ parseFileReferenceUrl?: (url: string | undefined) => string | null;
+ getDownloadedFile?: (path: string) => Promise;
+};
+
+function parseNativeFileReferenceUrl(url: string): string | null {
+ const client = NativeHelperClient as NativeFileReferenceClient;
+ return typeof client.parseFileReferenceUrl === 'function'
+ ? client.parseFileReferenceUrl(url)
+ : null;
+}
+
+async function getNativeFileBytes(path: string): Promise {
+ const client = NativeHelperClient as NativeFileReferenceClient;
+ return typeof client.getDownloadedFile === 'function'
+ ? client.getDownloadedFile(path)
+ : null;
+}
+
+async function fetchModelBytes(url: string): Promise<{ bytes: ArrayBuffer; contentType?: string } | null> {
+ const nativePath = parseNativeFileReferenceUrl(url);
+ if (nativePath) {
+ const bytes = await getNativeFileBytes(nativePath);
+ return bytes ? { bytes } : null;
+ }
+
+ const response = await fetch(url);
+ if (!response.ok) {
+ return null;
+ }
+
+ return {
+ bytes: await response.arrayBuffer(),
+ contentType: response.headers?.get('content-type') ?? undefined,
+ };
+}
+
+async function fetchModelText(url: string): Promise {
+ const nativePath = parseNativeFileReferenceUrl(url);
+ if (nativePath) {
+ const bytes = await getNativeFileBytes(nativePath);
+ return bytes ? new TextDecoder().decode(bytes) : null;
+ }
+
+ const response = await fetch(url);
+ return response.ok ? response.text() : null;
+}
+
export interface ModelRuntimeTexture {
image: ImageBitmap;
width: number;
@@ -724,11 +773,11 @@ async function resolveGltfBuffers(gltf: GltfAsset, sourceUrl: string, embeddedGl
} catch {
return null;
}
- const response = await fetch(resolvedUrl);
- if (!response.ok) {
+ const fetched = await fetchModelBytes(resolvedUrl);
+ if (!fetched) {
return null;
}
- resolved.push(await response.arrayBuffer());
+ resolved.push(fetched.bytes);
}
return resolved;
@@ -754,9 +803,9 @@ async function resolveGltfTextures(
if (image.uri) {
try {
const imageUrl = new URL(image.uri, sourceUrl).toString();
- const response = await fetch(imageUrl);
- imageTextures[index] = response.ok
- ? await createTextureFromBytes(await response.arrayBuffer(), image.mimeType ?? response.headers.get('content-type') ?? undefined)
+ const fetched = await fetchModelBytes(imageUrl);
+ imageTextures[index] = fetched
+ ? await createTextureFromBytes(fetched.bytes, image.mimeType ?? fetched.contentType)
: null;
} catch (error) {
log.warn('Failed to fetch model texture', { uri: image.uri, error });
@@ -1177,13 +1226,13 @@ export class ModelRuntimeCache {
): Promise {
const resolvedFileName = fileName ?? this.requests.get(url)?.fileName ?? url;
const extension = resolvedFileName.split('.').pop()?.toLowerCase() ?? '';
- const response = await fetch(url);
- if (!response.ok) {
- return null;
- }
if (extension === 'obj') {
- const parsedPrimitives = parseObj(await response.text());
+ const text = await fetchModelText(url);
+ if (!text) {
+ return null;
+ }
+ const parsedPrimitives = parseObj(text);
const sourceBounds = computeModelBounds(parsedPrimitives) ?? undefined;
const primitives = normalizeModelPrimitives(parsedPrimitives, normalizationBounds ?? sourceBounds ?? null);
return primitives.length > 0
@@ -1198,7 +1247,11 @@ export class ModelRuntimeCache {
: null;
}
- const buffer = await response.arrayBuffer();
+ const fetched = await fetchModelBytes(url);
+ if (!fetched) {
+ return null;
+ }
+ const buffer = fetched.bytes;
let gltf: GltfAsset | null = null;
let binaryChunk: ArrayBuffer | undefined;
let format: 'gltf' | 'glb' = extension === 'gltf' ? 'gltf' : 'glb';
diff --git a/src/engine/render/RenderDispatcher.ts b/src/engine/render/RenderDispatcher.ts
index 4a9af5845..5b355f06d 100644
--- a/src/engine/render/RenderDispatcher.ts
+++ b/src/engine/render/RenderDispatcher.ts
@@ -21,6 +21,7 @@ import { useSliceStore } from '../../stores/sliceStore';
import { useTimelineStore } from '../../stores/timeline';
import { reportRenderTime } from '../../services/performanceMonitor';
import { Logger } from '../../services/logger';
+import { NativeHelperClient } from '../../services/nativeHelper/NativeHelperClient';
import { scrubSettleState } from '../../services/scrubSettleState';
import { vfPipelineMonitor } from '../../services/vfPipelineMonitor';
import { getCopiedHtmlVideoPreviewFrame } from './htmlVideoPreviewFallback';
@@ -1694,6 +1695,23 @@ export class RenderDispatcher {
message: 'Fetching splat file',
});
+ const nativePath = NativeHelperClient.parseFileReferenceUrl(request.url);
+ if (nativePath) {
+ const arrayBuffer = await NativeHelperClient.getDownloadedFile(nativePath);
+ if (!arrayBuffer) {
+ throw new Error(`Failed to fetch native gaussian splat: ${nativePath}`);
+ }
+
+ this.setGaussianSplatLoadProgress(request, {
+ phase: 'fetching',
+ percent: 0.35,
+ loadedBytes: arrayBuffer.byteLength,
+ totalBytes: arrayBuffer.byteLength,
+ message: 'Fetched splat file',
+ });
+ return new File([arrayBuffer], request.fileName || nativePath.split(/[\\/]/).pop() || 'splat.ply');
+ }
+
const response = await fetch(request.url);
if (!response.ok) {
throw new Error(`Failed to fetch gaussian splat: ${response.status} ${response.statusText}`);
diff --git a/src/engine/scene/runtime/SharedSplatRuntimeCache.ts b/src/engine/scene/runtime/SharedSplatRuntimeCache.ts
index a6a3c9f07..ae3375c27 100644
--- a/src/engine/scene/runtime/SharedSplatRuntimeCache.ts
+++ b/src/engine/scene/runtime/SharedSplatRuntimeCache.ts
@@ -2,6 +2,7 @@ import { loadGaussianSplatAsset } from '../../gaussian/loaders';
import type { GaussianSplatAsset, GaussianSplatFormat } from '../../gaussian/loaders';
import { Logger } from '../../../services/logger';
import { projectFileService } from '../../../services/projectFileService';
+import { NativeHelperClient } from '../../../services/nativeHelper/NativeHelperClient';
import type { GaussianSplatBounds, GaussianSplatSequenceData } from '../../../types';
import {
cloneGaussianSplatBounds,
@@ -190,6 +191,19 @@ async function loadAsset(options: RuntimeSourceOptions): Promise = {}
mediaState.selectSlotComposition(null);
return { success: true, data: { action } };
}
+ case 'load-project-path': {
+ const projectPath = typeof args.path === 'string' ? args.path : '';
+ if (!projectPath) {
+ return { success: false, error: 'Missing path' };
+ }
+
+ const loaded = await projectFileService.loadProject(projectPath);
+ if (!loaded) {
+ return { success: false, error: `Failed to load project: ${projectPath}` };
+ }
+
+ await loadProjectToStores();
+ return {
+ success: true,
+ data: {
+ action,
+ projectPath,
+ projectName: projectFileService.getProjectData()?.name ?? null,
+ },
+ };
+ }
+ case 'reload-page': {
+ window.location.reload();
+ return { success: true, data: { action } };
+ }
default:
return { success: false, error: `Unknown debug action: ${action}` };
}
diff --git a/src/services/layerBuilder/LayerBuilderService.ts b/src/services/layerBuilder/LayerBuilderService.ts
index d1fdac329..4a485a5df 100644
--- a/src/services/layerBuilder/LayerBuilderService.ts
+++ b/src/services/layerBuilder/LayerBuilderService.ts
@@ -73,6 +73,10 @@ export class LayerBuilderService {
: undefined;
}
+ private getRenderableFile(file: File | undefined): File | undefined {
+ return file && (typeof file.size !== 'number' || file.size > 0) ? file : undefined;
+ }
+
private getLayerSourceMetadata(
clip: TimelineClip,
mediaFile?: { id?: string; width?: number; height?: number },
@@ -132,10 +136,10 @@ export class LayerBuilderService {
mediaFile?: { file?: File },
): File | undefined {
return (
- this.resolveClipGaussianSplatFrame(clip, sourceTime)?.file ??
- mediaFile?.file ??
- clip.source?.file ??
- clip.file
+ this.getRenderableFile(this.resolveClipGaussianSplatFrame(clip, sourceTime)?.file) ??
+ this.getRenderableFile(mediaFile?.file) ??
+ this.getRenderableFile(clip.source?.file) ??
+ this.getRenderableFile(clip.file)
);
}
@@ -926,7 +930,7 @@ export class LayerBuilderService {
modelUrl,
modelFileName: modelFrame?.name ?? clip.source?.modelFileName ?? clip.file?.name ?? clip.name,
...(modelSequence ? { modelSequence } : {}),
- file: clip.file,
+ file: this.getRenderableFile(clip.file),
threeDEffectorsEnabled: resolveSceneEffectorsEnabled(clip.source?.threeDEffectorsEnabled),
meshType,
text3DProperties,
diff --git a/src/services/nativeHelper/NativeHelperClient.ts b/src/services/nativeHelper/NativeHelperClient.ts
index 4fca52faf..7ab6b9317 100644
--- a/src/services/nativeHelper/NativeHelperClient.ts
+++ b/src/services/nativeHelper/NativeHelperClient.ts
@@ -30,11 +30,13 @@ import {
// In production, use a proper LZ4 library like 'lz4js'
const log = Logger.create('NativeHelper');
+const NATIVE_FILE_REFERENCE_PREFIX = 'native-helper-file://';
export interface NativeHelperConfig {
port?: number;
autoReconnect?: boolean;
reconnectInterval?: number;
+ connectTimeoutMs?: number;
token?: string;
/** Only reconnect if we were previously connected */
onlyReconnectIfWasConnected?: boolean;
@@ -51,6 +53,11 @@ export interface DecodedFrame {
}
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
+export interface NativeFolderPickResult {
+ path: string | null;
+ cancelled: boolean;
+ error?: string;
+}
type ResponseCallback = (response: Response) => void;
type FrameCallback = (frame: DecodedFrame) => void;
@@ -101,7 +108,8 @@ class NativeHelperClientImpl {
this.config = {
port: 9876,
autoReconnect: true,
- reconnectInterval: 10000, // 10 seconds between reconnect attempts
+ reconnectInterval: 2500,
+ connectTimeoutMs: 5000,
token: '',
onlyReconnectIfWasConnected: true, // Don't spam reconnects if never connected
};
@@ -150,11 +158,16 @@ class NativeHelperClientImpl {
const connectPromise = new Promise((resolve) => {
let settled = false;
+ let connectTimeout: ReturnType | null = null;
const finish = (result: boolean) => {
if (settled) {
return;
}
settled = true;
+ if (connectTimeout) {
+ clearTimeout(connectTimeout);
+ connectTimeout = null;
+ }
resolve(result);
};
@@ -163,41 +176,75 @@ class NativeHelperClientImpl {
this.ws = ws;
ws.binaryType = 'arraybuffer'; // Ensure binary data comes as ArrayBuffer, not Blob
+ connectTimeout = setTimeout(() => {
+ if (settled) {
+ return;
+ }
+ if (this.ws === ws) {
+ this.ws = null;
+ }
+ try {
+ ws.close();
+ } catch {
+ // Ignore close errors while unwinding a timed-out connection attempt.
+ }
+ this.setStatus('disconnected');
+ log.warn(`Native helper connection timed out after ${this.config.connectTimeoutMs}ms`);
+ finish(false);
+ }, this.config.connectTimeoutMs);
+
ws.onopen = async () => {
log.info('Connected to native helper');
this.wasEverConnected = true;
- // If no token configured, try to discover it from the startup endpoint
- if (!this.config.token) {
- try {
- const httpPort = this.config.port + 1;
- const resp = await fetch(`http://127.0.0.1:${httpPort}/startup-token`);
- if (resp.ok) {
- const data = await resp.json();
- if (data.token) {
- this.config.token = data.token;
- log.info('Auth token discovered from startup endpoint');
- }
+ // Refresh the token on every new socket. Helper restarts generate a
+ // new startup token, so a cached token from the previous process is stale.
+ try {
+ const httpPort = this.config.port + 1;
+ const resp = await this.fetchWithTimeout(
+ `http://127.0.0.1:${httpPort}/startup-token`,
+ undefined,
+ Math.min(this.config.connectTimeoutMs, 1500),
+ );
+ if (resp.ok) {
+ const data = await resp.json();
+ if (typeof data.token === 'string' && data.token.length > 0) {
+ this.config.token = data.token;
+ log.info('Auth token discovered from startup endpoint');
}
- } catch {
- log.debug('Could not discover auth token from startup endpoint');
}
+ } catch {
+ log.debug('Could not discover auth token from startup endpoint');
}
// Authenticate with token
if (this.config.token) {
+ let authenticated = false;
try {
const authResp = await this.send({ cmd: 'auth', id: this.nextId(), token: this.config.token });
- if (okField(authResp, 'authenticated') !== true) {
- log.warn('Auth response did not confirm authentication');
- }
+ authenticated = okField(authResp, 'authenticated') === true;
} catch {
log.warn('Auth failed');
}
+
+ if (!authenticated) {
+ log.warn('Auth response did not confirm authentication');
+ if (this.ws === ws) {
+ this.ws = null;
+ }
+ try {
+ ws.close();
+ } catch {
+ // Ignore close errors while unwinding a failed auth attempt.
+ }
+ this.setStatus('disconnected');
+ finish(false);
+ return;
+ }
}
try {
- await this.send({
+ const registerResponse = await this.send({
cmd: 'register_client',
id: this.nextId(),
role: 'editor',
@@ -205,8 +252,22 @@ class NativeHelperClientImpl {
session_name: 'masterselects-editor',
app_version: APP_VERSION,
});
+ if (registerResponse.ok !== true) {
+ throw new Error(getErrorMessage(registerResponse, 'Registration failed'));
+ }
} catch (error) {
log.warn('Editor registration with native helper failed', error);
+ if (this.ws === ws) {
+ this.ws = null;
+ }
+ try {
+ ws.close();
+ } catch {
+ // Ignore close errors while unwinding a failed registration attempt.
+ }
+ this.setStatus('disconnected');
+ finish(false);
+ return;
}
this.setStatus('connected');
@@ -227,6 +288,9 @@ class NativeHelperClientImpl {
ws.onerror = () => {
// Don't log errors when helper isn't running - it's optional
+ if (this.ws === ws) {
+ this.ws = null;
+ }
this.setStatus('disconnected');
finish(false);
};
@@ -429,9 +493,9 @@ class NativeHelperClientImpl {
/**
* Get system info
*/
- async getInfo(): Promise {
+ async getInfo(timeoutMs = 30000): Promise {
const id = this.nextId();
- const response = await this.send({ cmd: 'info', id });
+ const response = await this.send({ cmd: 'info', id }, timeoutMs);
if (!response.ok) {
throw new Error(getErrorMessage(response, 'Failed to get info'));
@@ -443,10 +507,10 @@ class NativeHelperClientImpl {
/**
* Ping the server
*/
- async ping(): Promise {
+ async ping(timeoutMs = 3000): Promise {
try {
const id = this.nextId();
- const response = await this.send({ cmd: 'ping', id });
+ const response = await this.send({ cmd: 'ping', id }, timeoutMs);
return response.ok === true;
} catch {
return false;
@@ -663,9 +727,9 @@ class NativeHelperClientImpl {
/**
* Get the default project root path from the native helper
*/
- async getProjectRoot(): Promise {
+ async getProjectRoot(timeoutMs = 1500): Promise {
try {
- const response = await this.fetchWithAuth(`${this.getHttpBaseUrl()}/project-root`);
+ const response = await this.fetchWithTimeout(`${this.getHttpBaseUrl()}/project-root`, undefined, timeoutMs);
if (response.ok) {
const data = await response.json();
return data.path || null;
@@ -673,7 +737,7 @@ class NativeHelperClientImpl {
} catch {
// Fallback to info command
try {
- const info = await this.getInfo();
+ const info = await this.getInfo(timeoutMs);
return info.project_root || null;
} catch {
return null;
@@ -682,12 +746,21 @@ class NativeHelperClientImpl {
return null;
}
- /**
+ /**
* Check if the native helper supports file system commands
*/
- async hasFsCommands(): Promise {
+ async hasFsCommands(timeoutMs = 1500): Promise {
+ try {
+ const response = await this.fetchWithTimeout(`${this.getHttpBaseUrl()}/project-root`, undefined, timeoutMs);
+ if (response.ok) {
+ return true;
+ }
+ } catch {
+ // Fall back to the older info-based check below.
+ }
+
try {
- const info = await this.getInfo();
+ const info = await this.getInfo(timeoutMs);
return info.fs_commands === true;
} catch {
return false;
@@ -768,6 +841,12 @@ class NativeHelperClientImpl {
const id = this.nextId();
try {
const response = await this.send({ cmd: 'create_dir', id, path, recursive });
+ if (response.ok !== true) {
+ log.warn('createDir rejected', {
+ path,
+ error: getErrorMessage(response, 'Create directory failed'),
+ });
+ }
return response.ok === true;
} catch (e) {
log.error('createDir failed', e);
@@ -840,26 +919,61 @@ class NativeHelperClientImpl {
/**
* Open a native OS folder picker dialog via the Native Helper.
- * Returns the selected folder path, or null if the user cancelled.
+ * Returns detailed picker state so callers can distinguish cancellation from
+ * platforms where the helper cannot show a native picker.
*/
- async pickFolder(title?: string, defaultPath?: string): Promise {
+ async pickFolderDetailed(title?: string, defaultPath?: string): Promise {
const id = this.nextId();
try {
const cmd = { cmd: 'pick_folder', id, title, default_path: defaultPath } satisfies JsonObject;
if (title) cmd.title = title;
if (defaultPath) cmd.default_path = defaultPath;
- const response = await this.send(cmd as unknown as Command);
+ const response = await this.send(cmd as unknown as Command, 5 * 60 * 1000);
const path = okField(response, 'path');
if (response.ok && path) {
- return path;
+ return { path, cancelled: false };
}
- return null; // cancelled
+ if (response.ok) {
+ return { path: null, cancelled: okField(response, 'cancelled') !== false };
+ }
+ return {
+ path: null,
+ cancelled: false,
+ error: getErrorMessage(response, 'Folder picker failed'),
+ };
} catch (e) {
log.error('pickFolder failed', e);
- return null;
+ return {
+ path: null,
+ cancelled: false,
+ error: e instanceof Error ? e.message : String(e),
+ };
+ }
+ }
+
+ /**
+ * Grant helper file access to a user-approved project path.
+ * Used after restoring paths persisted by the browser between sessions.
+ */
+ async grantPath(path: string): Promise {
+ const id = this.nextId();
+ try {
+ const response = await this.send({ cmd: 'grant_path', id, path });
+ return response.ok === true;
+ } catch (e) {
+ log.error('grantPath failed', e);
+ return false;
}
}
+ /**
+ * Open a native OS folder picker dialog via the Native Helper.
+ * Returns the selected folder path, or null if cancelled or unavailable.
+ */
+ async pickFolder(title?: string, defaultPath?: string): Promise {
+ return (await this.pickFolderDetailed(title, defaultPath)).path;
+ }
+
/**
* Build a URL that serves a file via the native helper HTTP server.
* Use this for media src attributes (video, audio, img) in Firefox.
@@ -868,6 +982,40 @@ class NativeHelperClientImpl {
return `${this.getHttpBaseUrl()}/file?path=${encodeURIComponent(absolutePath)}`;
}
+ /**
+ * Build an app-internal URL for files that must be fetched through the
+ * authenticated Native Helper client rather than by a DOM element.
+ */
+ getFileReferenceUrl(absolutePath: string): string {
+ return `${NATIVE_FILE_REFERENCE_PREFIX}${encodeURIComponent(absolutePath)}`;
+ }
+
+ parseFileReferenceUrl(url: string | undefined): string | null {
+ if (!url?.startsWith(NATIVE_FILE_REFERENCE_PREFIX)) {
+ return null;
+ }
+
+ try {
+ return decodeURIComponent(url.slice(NATIVE_FILE_REFERENCE_PREFIX.length));
+ } catch {
+ return null;
+ }
+ }
+
+ async getReferencedFile(url: string, fileName: string): Promise {
+ const path = this.parseFileReferenceUrl(url);
+ if (!path) {
+ return null;
+ }
+
+ const fileBuffer = await this.getDownloadedFile(path);
+ if (!fileBuffer) {
+ return null;
+ }
+
+ return new File([fileBuffer], fileName || path.split(/[\\/]/).pop() || 'file');
+ }
+
/**
* Get a downloaded file from the Native Helper via HTTP (fast) or WebSocket fallback
*/
@@ -1163,6 +1311,26 @@ class NativeHelperClientImpl {
return fetch(url, { ...init, headers });
}
+ private fetchWithTimeout(
+ url: string,
+ init?: RequestInit,
+ timeoutMs = 3000,
+ ): Promise {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
+
+ if (init?.signal) {
+ if (init.signal.aborted) {
+ controller.abort();
+ } else {
+ init.signal.addEventListener('abort', () => controller.abort(), { once: true });
+ }
+ }
+
+ return fetch(url, { ...init, signal: controller.signal })
+ .finally(() => clearTimeout(timeout));
+ }
+
private nextId(): string {
return `req_${++this.requestId}`;
}
@@ -1193,8 +1361,9 @@ class NativeHelperClientImpl {
this.status !== 'connecting' &&
(!this.config.onlyReconnectIfWasConnected || this.wasEverConnected);
- if (shouldReconnect) {
+ if (shouldReconnect && !this.reconnectTimer) {
this.reconnectTimer = window.setTimeout(() => {
+ this.reconnectTimer = null;
log.debug('Attempting reconnect...');
this.connect();
}, this.config.reconnectInterval);
@@ -1323,7 +1492,7 @@ class NativeHelperClientImpl {
}
}
- private async send(cmd: Command): Promise {
+ private async send(cmd: Command, timeoutMs = 30000): Promise {
if (!this.isConnected()) {
throw new Error('Not connected');
}
@@ -1335,7 +1504,7 @@ class NativeHelperClientImpl {
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error('Request timeout'));
- }, 30000);
+ }, timeoutMs);
// Register callback
this.pendingRequests.set(id, (response) => {
diff --git a/src/services/nativeHelper/protocol.ts b/src/services/nativeHelper/protocol.ts
index 111b89a04..1193e520a 100644
--- a/src/services/nativeHelper/protocol.ts
+++ b/src/services/nativeHelper/protocol.ts
@@ -230,6 +230,12 @@ export interface RenameCommand {
new_path: string;
}
+export interface GrantPathCommand {
+ cmd: 'grant_path';
+ id: string;
+ path: string;
+}
+
export interface DirEntry {
name: string;
kind: 'file' | 'directory';
@@ -312,6 +318,7 @@ export type Command =
| DeleteCommand
| ExistsCommand
| RenameCommand
+ | GrantPathCommand
| MatAnyoneStatusCommand
| MatAnyoneSetupCommand
| MatAnyoneDownloadModelCommand
diff --git a/src/services/nativeHelper/releases.ts b/src/services/nativeHelper/releases.ts
index 1be4e3d52..615e38482 100644
--- a/src/services/nativeHelper/releases.ts
+++ b/src/services/nativeHelper/releases.ts
@@ -1,4 +1,4 @@
-export const NATIVE_HELPER_TARGET_VERSION = '0.3.12';
+export const NATIVE_HELPER_TARGET_VERSION = '0.3.13';
export const NATIVE_HELPER_RELEASES_URL = 'https://github.com/Sportinger/MasterSelects/releases';
const GITHUB_RELEASES_API_URL = 'https://api.github.com/repos/Sportinger/MasterSelects/releases?per_page=20';
diff --git a/src/services/project/ProjectFileService.ts b/src/services/project/ProjectFileService.ts
index b7a9b8638..ee274328a 100644
--- a/src/services/project/ProjectFileService.ts
+++ b/src/services/project/ProjectFileService.ts
@@ -61,6 +61,74 @@ class ProjectFileService {
.join('/');
}
+ private normalizeNativePath(path: string): string {
+ return path.trim().replace(/\\/g, '/').replace(/\/+$/, '');
+ }
+
+ private ensureNativeBackend(): NativeProjectCoreService {
+ if (!this.nativeCoreService) {
+ this.nativeCoreService = new NativeProjectCoreService();
+ this.nativeFileStorage = nativeFileStorageService;
+ }
+ this._activeBackend = 'native';
+ return this.nativeCoreService;
+ }
+
+ private async ensureNativeBackendReady(): Promise {
+ const nativeCore = this.ensureNativeBackend();
+
+ if (!NativeHelperClient.isConnected()) {
+ const connected = await NativeHelperClient.connect();
+ if (!connected) {
+ log.warn('Native Helper backend requested but helper is not connected');
+ return null;
+ }
+ }
+
+ const hasFsCommands = await NativeHelperClient.hasFsCommands();
+ if (!hasFsCommands) {
+ log.error('Native Helper does not support project file-system commands');
+ return null;
+ }
+
+ return nativeCore;
+ }
+
+ private async pickNativeFolder(title: string, defaultPath?: string | null): Promise {
+ const fallbackPath = defaultPath ? this.normalizeNativePath(defaultPath) : '';
+ const result = await NativeHelperClient.pickFolderDetailed(title, fallbackPath || undefined);
+
+ if (result.path) {
+ const selectedPath = this.normalizeNativePath(result.path);
+ await NativeHelperClient.grantPath(selectedPath);
+ return selectedPath;
+ }
+
+ if (result.cancelled) {
+ return null;
+ }
+
+ log.warn('Native folder picker unavailable, falling back to manual path entry', {
+ title,
+ error: result.error,
+ });
+
+ const detectedRoot = fallbackPath || (await NativeHelperClient.getProjectRoot());
+ const promptDefault = detectedRoot || '';
+ const enteredPath = window.prompt(
+ `${title}\n\nNative folder picker is unavailable here. Enter the folder path manually:`,
+ promptDefault,
+ );
+
+ if (!enteredPath?.trim()) {
+ return null;
+ }
+
+ const selectedPath = this.normalizeNativePath(enteredPath);
+ await NativeHelperClient.grantPath(selectedPath);
+ return selectedPath;
+ }
+
private getMimeTypeFromFileName(fileName: string): string {
const extension = fileName.split('.').pop()?.toLowerCase() ?? '';
@@ -197,8 +265,9 @@ class ProjectFileService {
isSameEntry: async (other: FileSystemHandle) => other === handle,
queryPermission: async () => 'granted' as PermissionState,
requestPermission: async () => 'granted' as PermissionState,
- } as FileSystemFileHandle;
+ } as FileSystemFileHandle & { __nativePath?: string };
+ handle.__nativePath = fullPath;
return handle;
}
@@ -265,16 +334,14 @@ class ProjectFileService {
/** Check if FSA (File System Access API) is available */
get isFsaAvailable(): boolean {
- return 'showDirectoryPicker' in window && 'showSaveFilePicker' in window;
+ return typeof window !== 'undefined'
+ && 'showDirectoryPicker' in window
+ && 'showSaveFilePicker' in window;
}
/** Switch to native helper backend (for Firefox) */
activateNativeBackend(): void {
- if (!this.nativeCoreService) {
- this.nativeCoreService = new NativeProjectCoreService();
- this.nativeFileStorage = nativeFileStorageService;
- }
- this._activeBackend = 'native';
+ this.ensureNativeBackend();
log.info('Switched to Native Helper backend');
}
@@ -307,7 +374,7 @@ class ProjectFileService {
}
isSupported(): boolean {
- if (this._activeBackend === 'native') {
+ if (this._activeBackend === 'native' || !this.isFsaAvailable) {
return this.nativeCoreService?.isSupported() ?? false;
}
return this.coreService.isSupported();
@@ -315,7 +382,7 @@ class ProjectFileService {
getProjectHandle(): FileSystemDirectoryHandle | null {
// Only FSA backend has a handle
- if (this._activeBackend === 'fsa') {
+ if (this._activeBackend === 'fsa' && this.isFsaAvailable) {
return this.coreService.getProjectHandle();
}
return null;
@@ -358,6 +425,20 @@ class ProjectFileService {
}
async createProject(name: string): Promise {
+ if (this._activeBackend === 'native' || !this.isFsaAvailable) {
+ const nativeCore = await this.ensureNativeBackendReady();
+ if (!nativeCore) return false;
+
+ const projectRoot = await NativeHelperClient.getProjectRoot();
+ const parentPath = await this.pickNativeFolder(
+ 'Choose where to save your project',
+ projectRoot,
+ );
+
+ if (!parentPath) return false;
+ return nativeCore.createProjectAtPath(parentPath, name);
+ }
+
return this.core.createProject(name);
}
@@ -367,21 +448,28 @@ class ProjectFileService {
}
async openProject(): Promise {
- if (this._activeBackend === 'fsa') {
+ if (this._activeBackend === 'fsa' && this.isFsaAvailable) {
return this.coreService.openProject();
}
- // Native backend doesn't have a directory picker — use loadProject with a path
- log.warn('openProject() called on native backend — use loadProject(path) instead');
- return false;
+ const nativeCore = await this.ensureNativeBackendReady();
+ if (!nativeCore) return false;
+
+ const projectRoot = await NativeHelperClient.getProjectRoot();
+ const projectPath = await this.pickNativeFolder(
+ 'Select an existing project folder',
+ projectRoot,
+ );
+
+ if (!projectPath) return false;
+ return nativeCore.loadProject(projectPath);
}
async loadProject(handleOrPath: FileSystemDirectoryHandle | string): Promise {
if (typeof handleOrPath === 'string') {
- // Native path
- if (this.nativeCoreService) {
- return this.nativeCoreService.loadProject(handleOrPath);
- }
- return false;
+ const nativeCore = await this.ensureNativeBackendReady();
+ return nativeCore
+ ? nativeCore.loadProject(this.normalizeNativePath(handleOrPath))
+ : false;
}
// FSA handle
return this.coreService.loadProject(handleOrPath);
@@ -404,6 +492,11 @@ class ProjectFileService {
}
async restoreLastProject(): Promise {
+ if (this._activeBackend === 'native' || !this.isFsaAvailable) {
+ const nativeCore = await this.ensureNativeBackendReady();
+ return nativeCore ? nativeCore.restoreLastProject() : false;
+ }
+
return this.core.restoreLastProject();
}
@@ -544,6 +637,25 @@ class ProjectFileService {
return this.rawMediaService.getFileFromRaw(handle, relativePath);
}
+ resolveRawFilePath(relativePath: string | undefined): string | null {
+ if (this._activeBackend !== 'native' || !this.nativeCoreService || !relativePath) {
+ return null;
+ }
+
+ const projectPath = this.nativeCoreService.getProjectPath();
+ const target = parseRawRelativePath(relativePath);
+ if (!projectPath || !target) {
+ return null;
+ }
+
+ return this.joinPath(projectPath, target.relativePath);
+ }
+
+ resolveRawFileUrl(relativePath: string | undefined): string | null {
+ const fullPath = this.resolveRawFilePath(relativePath);
+ return fullPath ? NativeHelperClient.getFileReferenceUrl(fullPath) : null;
+ }
+
async hasFileInRaw(fileName: string): Promise {
const handle = this.coreService.getProjectHandle();
if (!handle) return false;
@@ -574,8 +686,37 @@ class ProjectFileService {
return this.scanDirectoryHandle(handle);
}
+ async pickAndScanFolder(title = 'Search folder for media'): Promise<{
+ name: string;
+ path?: string;
+ files: Map;
+ } | null> {
+ if (this._activeBackend !== 'native') {
+ return null;
+ }
+
+ const nativeCore = await this.ensureNativeBackendReady();
+ if (!nativeCore) {
+ return null;
+ }
+
+ const defaultPath = nativeCore.getProjectPath() ?? await NativeHelperClient.getProjectRoot();
+ const folderPath = await this.pickNativeFolder(title, defaultPath);
+ if (!folderPath) {
+ return null;
+ }
+
+ const normalizedPath = this.normalizeNativePath(folderPath);
+ const name = normalizedPath.split('/').filter(Boolean).pop() ?? normalizedPath;
+ return {
+ name,
+ path: normalizedPath,
+ files: await this.scanNativeFolder(normalizedPath),
+ };
+ }
+
async importMediaFile(file: File, fileHandle?: FileSystemFileHandle): Promise {
- const projectData = this.coreService.getProjectData();
+ const projectData = this.core.getProjectData();
if (!projectData) return null;
const mediaFile = await this.rawMediaService.importMediaFile(file, fileHandle);
@@ -583,7 +724,7 @@ class ProjectFileService {
// Add to project
projectData.media.push(mediaFile);
- this.coreService.markDirty();
+ this.core.markDirty();
return mediaFile;
}
diff --git a/src/services/project/core/NativeProjectCoreService.ts b/src/services/project/core/NativeProjectCoreService.ts
index 0631d67a0..e9123a85e 100644
--- a/src/services/project/core/NativeProjectCoreService.ts
+++ b/src/services/project/core/NativeProjectCoreService.ts
@@ -6,6 +6,7 @@ import { Logger } from '../../logger';
import { apiKeyManager } from '../../apiKeyManager';
import { NativeHelperClient } from '../../nativeHelper/NativeHelperClient';
import { PROJECT_FOLDER_PATHS, MAX_BACKUPS } from './constants';
+import { shouldPreferAutosave, shouldSkipEmptyProjectSave } from './autosaveRecovery';
import type { ProjectFile, ProjectMediaFile, ProjectComposition, ProjectFolder } from '../types';
const log = Logger.create('NativeProjectCore');
@@ -114,6 +115,7 @@ export class NativeProjectCoreService {
}
try {
+ await this.client.grantPath(basePath);
const projectPath = this.joinPath(basePath, name);
return await this.initializeProject(projectPath, name);
} catch (e) {
@@ -198,6 +200,7 @@ export class NativeProjectCoreService {
async loadProject(projectPath: string): Promise {
try {
+ await this.client.grantPath(projectPath);
const projectData = await this.readLatestProjectData(projectPath);
if (!projectData) {
@@ -258,6 +261,15 @@ export class NativeProjectCoreService {
try {
const savedRevision = this.dirtyRevision;
+ const autosaveData = await this.readProjectFile(this.projectPath, PROJECT_AUTOSAVE_FILE_NAME);
+ if (shouldSkipEmptyProjectSave(this.projectData, autosaveData)) {
+ log.warn('Skipped empty project save because project.autosave.json contains recoverable project data');
+ if (this.dirtyRevision === savedRevision) {
+ this.isDirty = false;
+ }
+ return true;
+ }
+
this.projectData.updatedAt = new Date().toISOString();
const jsonPath = this.joinPath(this.projectPath, PROJECT_FILE_NAME);
@@ -432,6 +444,7 @@ export class NativeProjectCoreService {
}
try {
+ await this.client.grantPath(lastPath);
const { exists, kind } = await this.client.exists(lastPath);
if (!exists || kind !== 'directory') {
log.info('Last project folder no longer exists');
@@ -562,11 +575,9 @@ export class NativeProjectCoreService {
if (!projectData) return null;
const autosaveData = await this.readProjectFile(projectPath, PROJECT_AUTOSAVE_FILE_NAME);
- const projectUpdatedAt = Date.parse(projectData.updatedAt);
- const autosaveUpdatedAt = autosaveData ? Date.parse(autosaveData.updatedAt) : NaN;
- if (autosaveData && autosaveUpdatedAt > projectUpdatedAt) {
- log.warn('Loaded newer project.autosave.json because project.json was older');
+ if (shouldPreferAutosave(projectData, autosaveData)) {
+ log.warn('Loaded project.autosave.json because it is newer or project.json appears empty');
return autosaveData;
}
diff --git a/src/services/project/core/ProjectCoreService.ts b/src/services/project/core/ProjectCoreService.ts
index 02f7df122..987fce661 100644
--- a/src/services/project/core/ProjectCoreService.ts
+++ b/src/services/project/core/ProjectCoreService.ts
@@ -4,6 +4,7 @@
import { Logger } from '../../logger';
import { projectDB } from '../../projectDB';
import { apiKeyManager } from '../../apiKeyManager';
+import { shouldPreferAutosave, shouldSkipEmptyProjectSave } from './autosaveRecovery';
const log = Logger.create('ProjectCore');
import { FileStorageService } from './FileStorageService';
@@ -295,6 +296,15 @@ export class ProjectCoreService {
try {
const savedRevision = this.dirtyRevision;
+ const autosaveData = await this.readProjectFile(this.projectHandle, PROJECT_AUTOSAVE_FILE_NAME);
+ if (shouldSkipEmptyProjectSave(this.projectData, autosaveData)) {
+ log.warn('Skipped empty project save because project.autosave.json contains recoverable project data');
+ if (this.dirtyRevision === savedRevision) {
+ this.isDirty = false;
+ }
+ return true;
+ }
+
this.projectData.updatedAt = new Date().toISOString();
await this.writeProjectJsonWithAutosaveFallback(this.projectHandle, this.projectData);
@@ -713,12 +723,10 @@ export class ProjectCoreService {
}
const autosaveData = await this.readProjectFile(handle, PROJECT_AUTOSAVE_FILE_NAME);
- const projectUpdatedAt = Date.parse(projectData.updatedAt);
- const autosaveUpdatedAt = autosaveData ? Date.parse(autosaveData.updatedAt) : NaN;
- if (autosaveData && autosaveUpdatedAt > projectUpdatedAt) {
- log.warn('Loaded newer project.autosave.json because project.json was older');
- return autosaveData;
+ if (shouldPreferAutosave(projectData, autosaveData)) {
+ log.warn('Loaded project.autosave.json because it is newer or project.json appears empty');
+ return autosaveData ?? projectData;
}
return projectData;
diff --git a/src/services/project/core/autosaveRecovery.ts b/src/services/project/core/autosaveRecovery.ts
new file mode 100644
index 000000000..1185260cd
--- /dev/null
+++ b/src/services/project/core/autosaveRecovery.ts
@@ -0,0 +1,53 @@
+import type { ProjectFile } from '../types';
+
+function clipCount(project: ProjectFile): number {
+ return project.compositions.reduce((count, composition) => count + composition.clips.length, 0);
+}
+
+function generatedItemCount(project: ProjectFile): number {
+ return (project.textItems?.length ?? 0)
+ + (project.solidItems?.length ?? 0)
+ + (project.meshItems?.length ?? 0)
+ + (project.cameraItems?.length ?? 0)
+ + (project.splatEffectorItems?.length ?? 0);
+}
+
+export function hasMeaningfulContent(project: ProjectFile): boolean {
+ return project.media.length > 0
+ || project.folders.length > 0
+ || project.compositions.length > 1
+ || clipCount(project) > 0
+ || generatedItemCount(project) > 0
+ || Boolean(project.flashboard?.boards?.some((board) => board.nodes.length > 0));
+}
+
+export function looksLikeFreshEmptyProject(project: ProjectFile): boolean {
+ return project.media.length === 0
+ && project.folders.length === 0
+ && project.compositions.length <= 1
+ && clipCount(project) === 0
+ && generatedItemCount(project) === 0;
+}
+
+export function shouldPreferAutosave(projectData: ProjectFile, autosaveData: ProjectFile | null): boolean {
+ if (!autosaveData) {
+ return false;
+ }
+
+ const projectUpdatedAt = Date.parse(projectData.updatedAt);
+ const autosaveUpdatedAt = Date.parse(autosaveData.updatedAt);
+
+ if (autosaveUpdatedAt > projectUpdatedAt) {
+ return true;
+ }
+
+ return looksLikeFreshEmptyProject(projectData) && hasMeaningfulContent(autosaveData);
+}
+
+export function shouldSkipEmptyProjectSave(projectData: ProjectFile, autosaveData: ProjectFile | null): boolean {
+ if (!autosaveData) {
+ return false;
+ }
+
+ return looksLikeFreshEmptyProject(projectData) && hasMeaningfulContent(autosaveData);
+}
diff --git a/src/services/project/projectLoad.ts b/src/services/project/projectLoad.ts
index 6ef7b4256..b0c282741 100644
--- a/src/services/project/projectLoad.ts
+++ b/src/services/project/projectLoad.ts
@@ -26,6 +26,7 @@ import {
type ProjectComposition,
type ProjectFolder,
} from '../projectFileService';
+import { withProjectStoreSyncGuard } from './projectSave';
import { fileSystemService } from '../fileSystemService';
import { projectDB } from '../projectDB';
import {
@@ -67,6 +68,29 @@ function removeLocalStorageKey(key: string): void {
storage.setItem(key, '');
}
+function isAbsoluteFilePath(value: string | undefined): boolean {
+ return Boolean(value && (value.startsWith('/') || /^[A-Za-z]:[/\\]/.test(value)));
+}
+
+type ProjectFileServiceRawResolver = typeof projectFileService & {
+ resolveRawFilePath?: (relativePath: string | undefined) => string | null;
+ resolveRawFileUrl?: (relativePath: string | undefined) => string | null;
+};
+
+function resolveProjectRawFilePath(relativePath: string | undefined): string | null {
+ const resolver = (projectFileService as ProjectFileServiceRawResolver).resolveRawFilePath;
+ return typeof resolver === 'function'
+ ? resolver.call(projectFileService, relativePath)
+ : null;
+}
+
+function resolveProjectRawFileUrl(relativePath: string | undefined): string | null {
+ const resolver = (projectFileService as ProjectFileServiceRawResolver).resolveRawFileUrl;
+ return typeof resolver === 'function'
+ ? resolver.call(projectFileService, relativePath)
+ : null;
+}
+
/**
* Calculate coverage ratio from time ranges vs total duration (0-1).
*/
@@ -147,7 +171,15 @@ async function restoreSequenceFrameFromHandle(
/**
* Convert ProjectMediaFile to MediaFile format
*/
-async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Promise {
+type ConvertProjectMediaOptions = {
+ hydrateFiles?: boolean;
+};
+
+async function convertProjectMediaToStore(
+ projectMedia: ProjectMediaFile[],
+ options: ConvertProjectMediaOptions = {},
+): Promise {
+ const hydrateFiles = options.hydrateFiles !== false;
const files: MediaFile[] = [];
for (const pm of projectMedia) {
@@ -157,18 +189,20 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro
let url = '';
let thumbnailUrl: string | undefined;
- // Prefer the project-local RAW copy. This is the canonical source for imported media.
- const storedProjectHandle = await getStoredProjectFileHandle(pm.id);
- if (storedProjectHandle) {
- try {
- file = await storedProjectHandle.getFile();
- handle = storedProjectHandle;
- url = URL.createObjectURL(file);
- resolvedProjectPath = resolvedProjectPath || `Raw/${storedProjectHandle.name}`;
- await cacheProjectFileHandle(pm.id, storedProjectHandle, true);
- log.info('Restored file from project RAW handle:', pm.name);
- } catch (e) {
- log.warn(`Could not access project RAW handle: ${pm.name}`, e);
+ if (hydrateFiles) {
+ // Prefer the project-local RAW copy. This is the canonical source for imported media.
+ const storedProjectHandle = await getStoredProjectFileHandle(pm.id);
+ if (storedProjectHandle) {
+ try {
+ file = await storedProjectHandle.getFile();
+ handle = storedProjectHandle;
+ url = URL.createObjectURL(file);
+ resolvedProjectPath = resolvedProjectPath || `Raw/${storedProjectHandle.name}`;
+ await cacheProjectFileHandle(pm.id, storedProjectHandle, true);
+ log.info('Restored file from project RAW handle:', pm.name);
+ } catch (e) {
+ log.warn(`Could not access project RAW handle: ${pm.name}`, e);
+ }
}
}
@@ -179,8 +213,12 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro
const frame = pm.modelSequence.frames[frameIndex];
let frameFile = frame.file;
let modelUrl = frame.modelUrl;
+ const frameFileUrl = resolveProjectRawFileUrl(frame.projectPath);
+ if (!hydrateFiles && !modelUrl && frameFileUrl) {
+ modelUrl = frameFileUrl;
+ }
- if (!frameFile && frame.projectPath && projectFileService.isProjectOpen()) {
+ if (hydrateFiles && !frameFile && frame.projectPath && projectFileService.isProjectOpen()) {
try {
const result = await projectFileService.getFileFromRaw(frame.projectPath);
if (result?.file) {
@@ -195,7 +233,7 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro
}
}
- if (!frameFile) {
+ if (hydrateFiles && !frameFile) {
const restoredFrame = await restoreSequenceFrameFromHandle(pm.id, frameIndex);
if (restoredFrame) {
frameFile = restoredFrame.file;
@@ -207,7 +245,7 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro
name: frame.name,
projectPath: frame.projectPath,
sourcePath: frame.sourcePath,
- absolutePath: frame.absolutePath,
+ absolutePath: frame.absolutePath ?? resolveProjectRawFilePath(frame.projectPath) ?? undefined,
file: frameFile,
modelUrl,
});
@@ -225,8 +263,12 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro
const frame = pm.gaussianSplatSequence.frames[frameIndex];
let frameFile = frame.file;
let splatUrl = frame.splatUrl;
+ const frameFileUrl = resolveProjectRawFileUrl(frame.projectPath);
+ if (!hydrateFiles && !splatUrl && frameFileUrl) {
+ splatUrl = frameFileUrl;
+ }
- if (!frameFile && frame.projectPath && projectFileService.isProjectOpen()) {
+ if (hydrateFiles && !frameFile && frame.projectPath && projectFileService.isProjectOpen()) {
try {
const result = await projectFileService.getFileFromRaw(frame.projectPath);
if (result?.file) {
@@ -241,7 +283,7 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro
}
}
- if (!frameFile) {
+ if (hydrateFiles && !frameFile) {
const restoredFrame = await restoreSequenceFrameFromHandle(pm.id, frameIndex);
if (restoredFrame) {
frameFile = restoredFrame.file;
@@ -253,7 +295,7 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro
name: frame.name,
projectPath: frame.projectPath,
sourcePath: frame.sourcePath,
- absolutePath: frame.absolutePath,
+ absolutePath: frame.absolutePath ?? resolveProjectRawFilePath(frame.projectPath) ?? undefined,
file: frameFile,
splatUrl,
splatCount: frame.splatCount,
@@ -268,7 +310,7 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro
frames: sequenceFrames,
};
}
- if (!file && projectFileService.isProjectOpen()) {
+ if (hydrateFiles && !file && projectFileService.isProjectOpen()) {
for (const candidatePath of getProjectRawPathCandidates({
mediaFileId: pm.id,
projectPath: pm.projectPath,
@@ -298,18 +340,28 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro
}
const representativeFile = file ?? modelSequence?.frames[0]?.file ?? gaussianSplatSequence?.frames[0]?.file;
+ const representativeProjectPath =
+ resolvedProjectPath ??
+ modelSequence?.frames[0]?.projectPath ??
+ gaussianSplatSequence?.frames[0]?.projectPath;
+ const nativeRepresentativeUrl =
+ !hydrateFiles && representativeProjectPath
+ ? resolveProjectRawFileUrl(representativeProjectPath) ?? ''
+ : '';
const representativeUrl =
url ||
modelSequence?.frames[0]?.modelUrl ||
gaussianSplatSequence?.frames[0]?.splatUrl ||
+ nativeRepresentativeUrl ||
'';
- const representativeProjectPath =
- resolvedProjectPath ??
- modelSequence?.frames[0]?.projectPath ??
- gaussianSplatSequence?.frames[0]?.projectPath;
+ const representativeAbsolutePath =
+ resolveProjectRawFilePath(representativeProjectPath) ??
+ (isAbsoluteFilePath(pm.sourcePath) ? pm.sourcePath : undefined) ??
+ modelSequence?.frames[0]?.absolutePath ??
+ gaussianSplatSequence?.frames[0]?.absolutePath;
// Fall back to the primary file handle for non-project media or legacy data.
- if (!file) {
+ if (hydrateFiles && !file) {
handle = fileSystemService.getFileHandle(pm.id);
if (!handle) {
@@ -411,8 +463,9 @@ async function convertProjectMediaToStore(projectMedia: ProjectMediaFile[]): Pro
modelSequence,
gaussianSplatSequence,
proxyStatus: pm.hasProxy ? 'ready' : 'none',
- hasFileHandle: !!handle,
+ hasFileHandle: !!handle || (!!representativeAbsolutePath && projectFileService.activeBackend === 'native'),
filePath: pm.sourcePath,
+ absolutePath: representativeAbsolutePath,
projectPath: representativeProjectPath,
vectorAnimation: pm.vectorAnimation,
labelColor: pm.labelColor as import('../../stores/mediaStore/types').LabelColor | undefined,
@@ -658,19 +711,27 @@ function hydrateFlashBoardFromProject(data: ProjectFlashBoardState): void {
* Load project data from projectFileService into stores
*/
export async function loadProjectToStores(): Promise {
- const projectData = projectFileService.getProjectData();
- if (!projectData) {
- log.error(' No project data to load');
- return;
- }
+ await withProjectStoreSyncGuard(async () => {
+ const projectData = projectFileService.getProjectData();
+ if (!projectData) {
+ log.error(' No project data to load');
+ return;
+ }
- // Convert and load data
- const files = await convertProjectMediaToStore(projectData.media);
- const compositions = convertProjectCompositionToStore(
- projectData.compositions,
- projectData.uiState?.compositionViewState
- );
- const folders = convertProjectFolderToStore(projectData.folders);
+ // Firefox/native helper must not synchronously download every RAW file or
+ // 3D sequence frame before the project appears in the UI.
+ const hydrateFiles = projectFileService.activeBackend !== 'native';
+ if (!hydrateFiles) {
+ log.info('Native backend detected; deferring media file hydration until after project metadata is loaded');
+ }
+
+ // Convert and load data
+ const files = await convertProjectMediaToStore(projectData.media, { hydrateFiles });
+ const compositions = convertProjectCompositionToStore(
+ projectData.compositions,
+ projectData.uiState?.compositionViewState
+ );
+ const folders = convertProjectFolderToStore(projectData.folders);
// Clear timeline first
const timelineStore = useTimelineStore.getState();
@@ -832,12 +893,17 @@ export async function loadProjectToStores(): Promise {
log.info(' Loaded project to stores:', projectData.name);
- // Auto-relink missing files from Raw folder
- await autoRelinkFromRawFolder();
+ if (hydrateFiles) {
+ // Auto-relink missing files from Raw folder
+ await autoRelinkFromRawFolder();
+ } else {
+ log.info('Skipping eager auto-relink for native backend during initial project load');
+ }
- // Restore thumbnails and refresh metadata in the background
- restoreMediaThumbnails();
- refreshMediaMetadata();
+ // Restore thumbnails and refresh metadata in the background
+ restoreMediaThumbnails();
+ refreshMediaMetadata();
+ });
}
// ============================================
diff --git a/src/services/project/projectSave.ts b/src/services/project/projectSave.ts
index 119a88666..f26f02bd0 100644
--- a/src/services/project/projectSave.ts
+++ b/src/services/project/projectSave.ts
@@ -55,6 +55,15 @@ export function isProjectStoreSyncInProgress(): boolean {
return projectStoreSyncInProgress;
}
+export async function withProjectStoreSyncGuard(work: () => Promise): Promise {
+ projectStoreSyncInProgress = true;
+ try {
+ return await work();
+ } finally {
+ projectStoreSyncInProgress = false;
+ }
+}
+
// ============================================
// CONVERTER HELPERS (store → project format)
// ============================================
@@ -351,8 +360,7 @@ function readMediaPanelViewMode(): 'classic' | 'icons' | 'board' | undefined {
* Sync current store state to projectFileService
*/
export async function syncStoresToProject(): Promise {
- projectStoreSyncInProgress = true;
- try {
+ await withProjectStoreSyncGuard(async () => {
const mediaState = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
@@ -480,9 +488,7 @@ export async function syncStoresToProject(): Promise {
}
log.info(' Synced stores to project');
- } finally {
- projectStoreSyncInProgress = false;
- }
+ });
}
/**
diff --git a/src/services/project/relinkMedia.ts b/src/services/project/relinkMedia.ts
index dfd5dedd8..957fedb32 100644
--- a/src/services/project/relinkMedia.ts
+++ b/src/services/project/relinkMedia.ts
@@ -37,6 +37,14 @@ function normalizePath(value: string): string {
return value.replace(/\\/g, '/');
}
+function isAbsolutePath(value: string | undefined): boolean {
+ return Boolean(value && (value.startsWith('/') || /^[A-Za-z]:[/\\]/.test(value)));
+}
+
+function getNativeHandlePath(handle: FileSystemFileHandle | undefined): string | undefined {
+ return (handle as (FileSystemFileHandle & { __nativePath?: string }) | undefined)?.__nativePath;
+}
+
function getBaseName(value: string | undefined): string | undefined {
if (!value) return undefined;
const normalized = normalizePath(value);
@@ -156,6 +164,7 @@ export async function createRelinkCandidateMapFromHandles(
const candidate: RelinkCandidate = {
name: handle.name,
handle,
+ absolutePath: getNativeHandlePath(handle),
};
candidates.set(candidate.name.toLowerCase(), candidate);
}
@@ -241,6 +250,46 @@ async function copyCandidateToProject(
return { file, handle, projectPath };
}
+function hasResolvableFramePath(frame: ModelSequenceFrame | GaussianSplatSequenceFrame): boolean {
+ return Boolean(
+ frame.projectPath ||
+ isAbsolutePath(frame.absolutePath) ||
+ isAbsolutePath(frame.sourcePath),
+ );
+}
+
+export function isNativeProjectLinkedMedia(mediaFile: MediaFile): boolean {
+ if (projectFileService.activeBackend !== 'native') {
+ return false;
+ }
+
+ if (mediaFile.file) {
+ return true;
+ }
+
+ if (
+ mediaFile.projectPath ||
+ isAbsolutePath(mediaFile.absolutePath) ||
+ isAbsolutePath(mediaFile.filePath)
+ ) {
+ return true;
+ }
+
+ if (mediaFile.modelSequence?.frames.length) {
+ return mediaFile.modelSequence.frames.every(hasResolvableFramePath);
+ }
+
+ if (mediaFile.gaussianSplatSequence?.frames.length) {
+ return mediaFile.gaussianSplatSequence.frames.every(hasResolvableFramePath);
+ }
+
+ return false;
+}
+
+export function mediaNeedsRelink(mediaFile: MediaFile): boolean {
+ return !mediaFile.file && !isNativeProjectLinkedMedia(mediaFile);
+}
+
function isBlobUrl(url: string | undefined): boolean {
return typeof url === 'string' && url.startsWith('blob:');
}
@@ -261,7 +310,35 @@ function replaceMediaFile(mediaFileId: string, nextFile: Partial): vo
}));
}
+async function applyNativeSingleRelink(
+ mediaFile: MediaFile,
+ match: Extract,
+): Promise {
+ const targetPath = mediaFile.projectPath ?? match.candidate.name;
+ const absolutePath =
+ match.candidate.absolutePath ??
+ projectFileService.resolveRawFilePath(targetPath) ??
+ mediaFile.absolutePath;
+
+ await storeHandle(mediaFile.id, match.candidate.handle);
+
+ replaceMediaFile(mediaFile.id, {
+ file: undefined,
+ url: '',
+ filePath: absolutePath ?? targetPath,
+ absolutePath,
+ projectPath: targetPath,
+ fileSize: mediaFile.fileSize,
+ });
+
+ return true;
+}
+
async function applySingleRelink(mediaFile: MediaFile, match: Extract): Promise {
+ if (projectFileService.activeBackend === 'native' && !match.candidate.file) {
+ return applyNativeSingleRelink(mediaFile, match);
+ }
+
const targetPath = mediaFile.projectPath ?? match.candidate.name;
const restored = await copyCandidateToProject(match.candidate, targetPath);
const url = URL.createObjectURL(restored.file);
@@ -284,10 +361,69 @@ async function applySingleRelink(mediaFile: MediaFile, match: Extract,
+): Promise {
+ const sequence = mediaFile.modelSequence;
+ if (!sequence) return false;
+
+ const frames = [...sequence.frames];
+ for (const { index, candidate } of match.frames) {
+ const existingFrame = frames[index];
+ if (!existingFrame) continue;
+
+ const projectPath = buildSequenceRawTarget(
+ existingFrame.projectPath,
+ sequence.sequenceName,
+ candidate.name,
+ 'glb-sequence',
+ );
+ const absolutePath =
+ candidate.absolutePath ??
+ projectFileService.resolveRawFilePath(projectPath) ??
+ existingFrame.absolutePath;
+
+ await storeHandle(`${mediaFile.id}_frame_${index}`, candidate.handle);
+ if (index === 0) {
+ await storeHandle(mediaFile.id, candidate.handle);
+ await storeHandle(`${mediaFile.id}_project`, candidate.handle);
+ }
+
+ frames[index] = {
+ ...existingFrame,
+ name: candidate.name,
+ sourcePath: absolutePath ?? candidate.name,
+ absolutePath,
+ projectPath,
+ };
+ }
+
+ const firstFrame = frames[0];
+ replaceMediaFile(mediaFile.id, {
+ file: undefined,
+ url: '',
+ modelSequence: {
+ ...sequence,
+ frames,
+ },
+ filePath: firstFrame?.sourcePath,
+ absolutePath: firstFrame?.absolutePath,
+ projectPath: firstFrame?.projectPath,
+ fileSize: mediaFile.fileSize,
+ });
+
+ return true;
+}
+
async function applyModelSequenceRelink(
mediaFile: MediaFile,
match: Extract,
): Promise {
+ if (projectFileService.activeBackend === 'native' && match.frames.every(({ candidate }) => !candidate.file)) {
+ return applyNativeModelSequenceRelink(mediaFile, match);
+ }
+
const sequence = mediaFile.modelSequence;
if (!sequence) return false;
@@ -349,10 +485,72 @@ async function applyModelSequenceRelink(
return true;
}
+async function applyNativeGaussianSplatSequenceRelink(
+ mediaFile: MediaFile,
+ match: Extract,
+): Promise {
+ const sequence = mediaFile.gaussianSplatSequence;
+ if (!sequence) return false;
+
+ const frames = [...sequence.frames];
+ for (const { index, candidate } of match.frames) {
+ const existingFrame = frames[index];
+ if (!existingFrame) continue;
+
+ const projectPath = buildSequenceRawTarget(
+ existingFrame.projectPath,
+ sequence.sequenceName,
+ candidate.name,
+ 'splat-sequence',
+ );
+ const absolutePath =
+ candidate.absolutePath ??
+ projectFileService.resolveRawFilePath(projectPath) ??
+ existingFrame.absolutePath;
+
+ await storeHandle(`${mediaFile.id}_frame_${index}`, candidate.handle);
+ if (index === 0) {
+ await storeHandle(mediaFile.id, candidate.handle);
+ await storeHandle(`${mediaFile.id}_project`, candidate.handle);
+ }
+
+ frames[index] = {
+ ...existingFrame,
+ name: candidate.name,
+ sourcePath: absolutePath ?? candidate.name,
+ absolutePath,
+ projectPath,
+ };
+ }
+
+ const firstFrame = frames[0];
+ const totalFileSize = frames.reduce((sum, frame) => sum + (frame.fileSize ?? 0), 0);
+ replaceMediaFile(mediaFile.id, {
+ file: undefined,
+ url: '',
+ gaussianSplatSequence: {
+ ...sequence,
+ frames,
+ totalFileSize: totalFileSize || sequence.totalFileSize,
+ },
+ filePath: firstFrame?.sourcePath,
+ absolutePath: firstFrame?.absolutePath,
+ projectPath: firstFrame?.projectPath,
+ fileSize: totalFileSize || mediaFile.fileSize,
+ splatFrameCount: sequence.frameCount,
+ });
+
+ return true;
+}
+
async function applyGaussianSplatSequenceRelink(
mediaFile: MediaFile,
match: Extract,
): Promise {
+ if (projectFileService.activeBackend === 'native' && match.frames.every(({ candidate }) => !candidate.file)) {
+ return applyNativeGaussianSplatSequenceRelink(mediaFile, match);
+ }
+
const sequence = mediaFile.gaussianSplatSequence;
if (!sequence) return false;
diff --git a/src/stores/timeline/clip/addGaussianSplatClip.ts b/src/stores/timeline/clip/addGaussianSplatClip.ts
index 50d4843d9..d8274961f 100644
--- a/src/stores/timeline/clip/addGaussianSplatClip.ts
+++ b/src/stores/timeline/clip/addGaussianSplatClip.ts
@@ -1,4 +1,4 @@
-// Gaussian Splat clip addition — PLY/splat/ksplat scene files
+// Gaussian Splat clip addition - PLY/splat/ksplat scene files
// Creates a timeline clip with is3D=true that renders via the gaussian splat pipeline
import type { GaussianSplatSequenceData, TimelineClip } from '../../../types';
@@ -20,6 +20,9 @@ export interface AddGaussianSplatClipParams {
estimatedDuration: number;
mediaFileId?: string;
gaussianSplatSequence?: GaussianSplatSequenceData;
+ gaussianSplatUrl?: string;
+ gaussianSplatFileName?: string;
+ gaussianSplatRuntimeKey?: string;
}
/**
@@ -27,7 +30,16 @@ export interface AddGaussianSplatClipParams {
* Auto-sets is3D=true so it renders via the 3D pipeline.
*/
export function createGaussianSplatClipPlaceholder(params: AddGaussianSplatClipParams): TimelineClip {
- const { trackId, file, startTime, estimatedDuration, gaussianSplatSequence } = params;
+ const {
+ trackId,
+ file,
+ startTime,
+ estimatedDuration,
+ gaussianSplatSequence,
+ gaussianSplatUrl,
+ gaussianSplatFileName,
+ gaussianSplatRuntimeKey,
+ } = params;
const clipId = generateClipId('clip-gsplat');
const naturalDuration = gaussianSplatSequence
? estimatedDuration || DEFAULT_SPLAT_DURATION
@@ -48,16 +60,19 @@ export function createGaussianSplatClipPlaceholder(params: AddGaussianSplatClipP
mediaFileId: params.mediaFileId,
threeDEffectorsEnabled: true,
...(gaussianSplatSequence ? { gaussianSplatSequence } : {}),
+ ...(gaussianSplatUrl ? { gaussianSplatUrl } : {}),
+ ...(gaussianSplatFileName ? { gaussianSplatFileName } : {}),
+ ...(gaussianSplatRuntimeKey ? { gaussianSplatRuntimeKey } : {}),
gaussianSplatSettings: resolveGaussianSplatSettingsForSource(undefined, {
- fileName: file.name,
+ fileName: gaussianSplatFileName ?? file.name,
sequence: gaussianSplatSequence,
}),
},
mediaFileId: params.mediaFileId,
transform: { ...DEFAULT_TRANSFORM },
effects: [],
- is3D: true, // Auto-enable 3D for gaussian splat clips
- isLoading: true, // Splat takes time to load
+ is3D: true,
+ isLoading: true,
};
}
@@ -67,38 +82,48 @@ export interface LoadGaussianSplatMediaParams {
}
/**
- * "Load" gaussian splat media — creates blob URL for the renderer to load later.
- * No HTMLVideoElement or HTMLImageElement needed.
+ * "Load" gaussian splat media by attaching a URL for the renderer to fetch.
+ * Restored Native projects may only have a reference URL, so an empty
+ * placeholder File must not be preferred over that URL.
*/
export function loadGaussianSplatMedia(params: LoadGaussianSplatMediaParams): void {
const { clip, updateClip } = params;
- if (!clip.file) {
- console.error('[GaussianSplat] loadGaussianSplatMedia: clip.file is missing — cannot create blob URL', clip.id);
- updateClip(clip.id, { isLoading: false });
- return;
- }
-
try {
- // Create a blob URL that the gaussian splat renderer can fetch
const sequenceFrame = clip.source?.gaussianSplatSequence?.frames[0];
- const gaussianSplatUrl = sequenceFrame?.splatUrl ?? blobUrlManager.create(clip.id, clip.file, 'model');
+ const renderableFile = clip.file?.size ? clip.file : undefined;
+ const gaussianSplatUrl = sequenceFrame?.splatUrl
+ ?? clip.source?.gaussianSplatUrl
+ ?? (renderableFile ? blobUrlManager.create(clip.id, renderableFile, 'model') : undefined);
+ const gaussianSplatFileName =
+ sequenceFrame?.name ??
+ clip.source?.gaussianSplatFileName ??
+ clip.file?.name ??
+ 'gaussian-splat';
const runtimeKey =
sequenceFrame?.projectPath ??
sequenceFrame?.absolutePath ??
sequenceFrame?.sourcePath ??
- sequenceFrame?.name;
+ sequenceFrame?.name ??
+ clip.source?.gaussianSplatRuntimeKey ??
+ clip.source?.gaussianSplatUrl;
+
+ if (!gaussianSplatUrl) {
+ console.error('[GaussianSplat] loadGaussianSplatMedia: no renderable file or URL', clip.id);
+ updateClip(clip.id, { isLoading: false });
+ return;
+ }
updateClip(clip.id, {
source: {
...clip.source!,
gaussianSplatUrl,
- gaussianSplatFileName: sequenceFrame?.name ?? clip.file.name,
+ gaussianSplatFileName,
gaussianSplatRuntimeKey: runtimeKey,
gaussianSplatSettings: resolveGaussianSplatSettingsForSource(
clip.source?.gaussianSplatSettings,
{
- fileName: sequenceFrame?.name ?? clip.file.name,
+ fileName: gaussianSplatFileName,
sequence: clip.source?.gaussianSplatSequence,
},
),
@@ -108,9 +133,9 @@ export function loadGaussianSplatMedia(params: LoadGaussianSplatMediaParams): vo
prewarmGaussianSplatRuntime({
cacheKey: runtimeKey || clip.mediaFileId || clip.source?.mediaFileId || clip.id,
- file: clip.file,
+ file: renderableFile,
url: gaussianSplatUrl,
- fileName: sequenceFrame?.name ?? clip.file.name,
+ fileName: gaussianSplatFileName,
gaussianSplatSequence: clip.source?.gaussianSplatSequence,
requestedMaxSplats: clip.source?.gaussianSplatSettings?.render.maxSplats ?? 0,
});
diff --git a/src/stores/timeline/clip/addModelClip.ts b/src/stores/timeline/clip/addModelClip.ts
index 522abdf8a..5bcec548b 100644
--- a/src/stores/timeline/clip/addModelClip.ts
+++ b/src/stores/timeline/clip/addModelClip.ts
@@ -16,6 +16,8 @@ export interface AddModelClipParams {
estimatedDuration: number;
mediaFileId?: string;
modelSequence?: ModelSequenceData;
+ modelUrl?: string;
+ modelFileName?: string;
}
/**
@@ -23,7 +25,7 @@ export interface AddModelClipParams {
* Auto-sets is3D=true so it renders via the shared 3D scene.
*/
export function createModelClipPlaceholder(params: AddModelClipParams): TimelineClip {
- const { trackId, file, startTime, estimatedDuration, modelSequence } = params;
+ const { trackId, file, startTime, estimatedDuration, modelSequence, modelUrl, modelFileName } = params;
const clipId = generateClipId('clip-3d');
const naturalDuration = modelSequence
? estimatedDuration || DEFAULT_MODEL_DURATION
@@ -44,6 +46,8 @@ export function createModelClipPlaceholder(params: AddModelClipParams): Timeline
mediaFileId: params.mediaFileId,
threeDEffectorsEnabled: true,
...(modelSequence ? { modelSequence } : {}),
+ ...(modelUrl ? { modelUrl } : {}),
+ ...(modelFileName ? { modelFileName } : {}),
},
mediaFileId: params.mediaFileId,
transform: { ...DEFAULT_TRANSFORM },
@@ -65,14 +69,23 @@ export interface LoadModelMediaParams {
export function loadModelMedia(params: LoadModelMediaParams): void {
const { clip, updateClip } = params;
const sequenceModelUrl = clip.source?.modelSequence?.frames[0]?.modelUrl;
+ const renderableFile = clip.file?.size ? clip.file : undefined;
// Create a blob URL that the shared scene renderer can fetch
- const modelUrl = sequenceModelUrl ?? blobUrlManager.create(clip.id, clip.file, 'model');
+ const modelUrl = sequenceModelUrl
+ ?? clip.source?.modelUrl
+ ?? (renderableFile ? blobUrlManager.create(clip.id, renderableFile, 'model') : undefined);
+
+ if (!modelUrl) {
+ updateClip(clip.id, { isLoading: false });
+ return;
+ }
updateClip(clip.id, {
source: {
...clip.source!,
modelUrl,
+ modelFileName: clip.source?.modelFileName ?? clip.file.name,
},
isLoading: false,
});
diff --git a/src/stores/timeline/clipSlice.ts b/src/stores/timeline/clipSlice.ts
index dd5ea1ab9..24cba710d 100644
--- a/src/stores/timeline/clipSlice.ts
+++ b/src/stores/timeline/clipSlice.ts
@@ -101,6 +101,11 @@ export const createClipSlice: SliceCreator = (set, get) => ({
modelSequence?: import('../../types').ModelSequenceData;
gaussianSplatSequence?: import('../../types').GaussianSplatSequenceData;
vectorAnimation?: import('../../types').VectorAnimationMetadata;
+ url?: string;
+ name?: string;
+ projectPath?: string;
+ absolutePath?: string;
+ filePath?: string;
}
| undefined;
if (mediaFileId) {
@@ -285,6 +290,8 @@ export const createClipSlice: SliceCreator = (set, get) => ({
estimatedDuration: modelSequenceDuration ?? providedDuration ?? 10,
mediaFileId,
modelSequence: sourceMediaFile?.modelSequence,
+ modelUrl: sourceMediaFile?.url,
+ modelFileName: sourceMediaFile?.name ?? file.name,
});
modelClip.mediaFileId = mediaFileId; // Link to MediaFile for nested comp lookup
set({ clips: [...clips, modelClip] });
@@ -314,6 +321,13 @@ export const createClipSlice: SliceCreator = (set, get) => ({
estimatedDuration: gaussianSplatSequenceDuration ?? providedDuration ?? 30,
mediaFileId,
gaussianSplatSequence: sourceMediaFile?.gaussianSplatSequence,
+ gaussianSplatUrl: sourceMediaFile?.url,
+ gaussianSplatFileName: sourceMediaFile?.name ?? file.name,
+ gaussianSplatRuntimeKey:
+ sourceMediaFile?.projectPath ??
+ sourceMediaFile?.absolutePath ??
+ sourceMediaFile?.filePath ??
+ sourceMediaFile?.url,
});
splatClip.mediaFileId = mediaFileId; // Link to MediaFile for nested comp lookup
set({ clips: [...clips, splatClip] });
diff --git a/src/stores/timeline/serializationUtils.ts b/src/stores/timeline/serializationUtils.ts
index 4f1404004..e7ba193f1 100644
--- a/src/stores/timeline/serializationUtils.ts
+++ b/src/stores/timeline/serializationUtils.ts
@@ -13,9 +13,11 @@ import { DEFAULT_TRACKS, MAX_NESTING_DEPTH } from './constants';
import { useMediaStore } from '../mediaStore';
import { calculateNestedClipBoundaries, buildClipSegments } from './clip/addCompClip';
import { projectFileService } from '../../services/projectFileService';
+import { mediaNeedsRelink } from '../../services/project/relinkMedia';
import { Logger } from '../../services/logger';
import { engine } from '../../engine/WebGPUEngine';
import { layerBuilder } from '../../services/layerBuilder';
+import { NativeHelperClient } from '../../services/nativeHelper/NativeHelperClient';
import { sanitizePlayheadPosition } from '../../services/layerBuilder/PlayheadState';
import { thumbnailCacheService } from '../../services/thumbnailCacheService';
import type { WebCodecsPlayer } from '../../engine/WebCodecsPlayer';
@@ -547,7 +549,8 @@ export const createSerializationUtils: SliceCreator = (set,
const mf = mediaStore.files.find(f => f.id === nsc.mediaFileId);
if (!mf) continue;
- const hf = !!(mf.file);
+ const hasBrowserFile = !!mf.file;
+ const needsReload = mediaNeedsRelink(mf);
const nc: TimelineClip = {
id: `nested-${parentClipId}-${nsc.id}`,
trackId: nsc.trackId,
@@ -557,7 +560,7 @@ export const createSerializationUtils: SliceCreator = (set,
duration: nsc.duration,
inPoint: nsc.inPoint,
outPoint: nsc.outPoint,
- source: hf ? null : {
+ source: hasBrowserFile ? null : {
type: nsc.sourceType || 'video',
naturalDuration: nsc.naturalDuration || nsc.duration,
mediaFileId: nsc.mediaFileId,
@@ -573,12 +576,12 @@ export const createSerializationUtils: SliceCreator = (set,
is3D: nsc.is3D,
meshType: nsc.meshType,
text3DProperties: nsc.text3DProperties ? { ...nsc.text3DProperties } : undefined,
- isLoading: hf,
- needsReload: !hf,
+ isLoading: hasBrowserFile,
+ needsReload,
};
result.push(nc);
// Load media element for sub-nested clips
- if (hf) {
+ if (hasBrowserFile) {
const subFileUrl = URL.createObjectURL(mf.file!);
const subType = nsc.sourceType;
if (subType === 'video') {
@@ -689,7 +692,7 @@ export const createSerializationUtils: SliceCreator = (set,
}
const nestedMediaFile = mediaStore.files.find(f => f.id === nestedSerializedClip.mediaFileId);
- const hasFile = !!(nestedMediaFile?.file);
+ const hasBrowserFile = !!(nestedMediaFile?.file);
if (!nestedMediaFile) {
log.warn('Skipping nested clip - media file entry not found', {
@@ -710,7 +713,7 @@ export const createSerializationUtils: SliceCreator = (set,
duration: nestedSerializedClip.duration,
inPoint: nestedSerializedClip.inPoint,
outPoint: nestedSerializedClip.outPoint,
- source: hasFile ? null : {
+ source: hasBrowserFile ? null : {
type: nestedSerializedClip.sourceType || 'video',
naturalDuration: nestedSerializedClip.naturalDuration || nestedSerializedClip.duration,
mediaFileId: nestedSerializedClip.mediaFileId,
@@ -725,15 +728,26 @@ export const createSerializationUtils: SliceCreator = (set,
is3D: nestedSerializedClip.is3D,
meshType: nestedSerializedClip.meshType,
text3DProperties: nestedSerializedClip.text3DProperties ? { ...nestedSerializedClip.text3DProperties } : undefined,
- isLoading: hasFile,
- needsReload: !hasFile,
+ isLoading: hasBrowserFile,
+ needsReload: mediaNeedsRelink(nestedMediaFile),
};
nestedClips.push(nestedClip);
// Only load media element if file is available
- if (!hasFile) {
- log.warn('Nested clip needs reload - file not available', {
+ if (!hasBrowserFile) {
+ if (nestedClip.needsReload) {
+ log.warn('Nested clip needs reload - file not available', {
+ clipName: nestedSerializedClip.name,
+ trackId: nestedSerializedClip.trackId,
+ mediaFileId: nestedSerializedClip.mediaFileId,
+ });
+ }
+ continue;
+ }
+
+ if (!nestedMediaFile.file) {
+ log.warn('Nested clip skipped - file object not available', {
clipName: nestedSerializedClip.name,
trackId: nestedSerializedClip.trackId,
mediaFileId: nestedSerializedClip.mediaFileId,
@@ -1179,8 +1193,9 @@ export const createSerializationUtils: SliceCreator = (set,
continue;
}
- // Create the clip - even if file is missing (needs reload after refresh)
- const needsReload = !mediaFile.file;
+ // Create the clip - even if the browser File object is not in memory.
+ // Native-helper projects can restore from persisted project/absolute paths.
+ const needsReload = mediaNeedsRelink(mediaFile);
if (needsReload) {
log.debug('Clip needs reload (file permission required)', { clip: serializedClip.name });
}
@@ -1289,7 +1304,7 @@ export const createSerializationUtils: SliceCreator = (set,
});
}
- // Skip media loading if file needs reload (no valid File object)
+ // Skip media loading if the media has no stored path/handle to recover from.
if (needsReload) {
log.debug('Skipping media load for clip that needs reload', { clip: clip.name });
continue;
@@ -1297,7 +1312,62 @@ export const createSerializationUtils: SliceCreator = (set,
// Load media element async
const type = serializedClip.sourceType;
- const fileUrl = URL.createObjectURL(mediaFile.file!);
+ let loadFile = mediaFile.file;
+ let fileUrl = loadFile ? URL.createObjectURL(loadFile) : mediaFile.url;
+
+ if (
+ !loadFile &&
+ fileUrl &&
+ (type === 'video' || type === 'audio' || type === 'image' || type === 'lottie') &&
+ NativeHelperClient.parseFileReferenceUrl(fileUrl)
+ ) {
+ const referencedFile = await NativeHelperClient.getReferencedFile(fileUrl, mediaFile.name);
+ if (referencedFile) {
+ loadFile = referencedFile;
+ fileUrl = URL.createObjectURL(referencedFile);
+ useMediaStore.setState((state) => ({
+ files: state.files.map((currentFile) =>
+ currentFile.id === mediaFile.id
+ ? {
+ ...currentFile,
+ file: referencedFile,
+ url: fileUrl,
+ hasFileHandle: true,
+ }
+ : currentFile
+ ),
+ }));
+ }
+ }
+
+ if (type === 'video' && !loadFile && mediaFile.absolutePath && projectFileService.activeBackend === 'native') {
+ set(state => ({
+ clips: state.clips.map(c =>
+ c.id === clip.id
+ ? {
+ ...c,
+ source: {
+ type: 'video',
+ naturalDuration: serializedClip.naturalDuration || mediaFile.duration || clip.duration,
+ mediaFileId: serializedClip.mediaFileId,
+ filePath: mediaFile.absolutePath,
+ },
+ isLoading: false,
+ needsReload: false,
+ }
+ : c
+ ),
+ }));
+ continue;
+ }
+
+ if (!fileUrl && (type === 'video' || type === 'audio' || type === 'image' || type === 'model' || type === 'gaussian-splat')) {
+ log.warn('Skipping media load - no file URL available', { clip: clip.name, mediaFileId: mediaFile.id });
+ set(state => ({
+ clips: state.clips.map(c => c.id === clip.id ? { ...c, isLoading: false } : c),
+ }));
+ continue;
+ }
if (type === 'video') {
const video = document.createElement('video');
@@ -1380,7 +1450,7 @@ export const createSerializationUtils: SliceCreator = (set,
if (hasWebCodecs && mediaId) {
try {
const webCodecsPlayer = await getOrCreateWcp(
- mediaId, video, clip.name, mediaFile.file || undefined
+ mediaId, video, clip.name, loadFile || undefined
);
if (webCodecsPlayer) {
set(state => ({
@@ -1448,11 +1518,23 @@ export const createSerializationUtils: SliceCreator = (set,
wakePreviewAfterRestore();
}, { once: true });
} else if (type === 'lottie') {
+ if (!loadFile) {
+ log.warn('Skipping lottie restore - file object not available', { clip: clip.name });
+ set((state) => ({
+ clips: state.clips.map((currentClip) =>
+ currentClip.id === clip.id
+ ? { ...currentClip, isLoading: false, needsReload: mediaNeedsRelink(mediaFile) }
+ : currentClip
+ ),
+ }));
+ continue;
+ }
+
void (async () => {
try {
const runtimeClip: TimelineClip = {
...clip,
- file: mediaFile.file!,
+ file: loadFile,
source: {
type: 'lottie',
mediaFileId: serializedClip.mediaFileId,
@@ -1460,14 +1542,14 @@ export const createSerializationUtils: SliceCreator = (set,
vectorAnimationSettings: serializedClip.vectorAnimationSettings,
},
};
- const metadata = await readLottieMetadata(mediaFile.file!);
- const runtime = await lottieRuntimeManager.prepareClipSource(runtimeClip, mediaFile.file!);
+ const metadata = await readLottieMetadata(loadFile);
+ const runtime = await lottieRuntimeManager.prepareClipSource(runtimeClip, loadFile);
set((state) => ({
clips: state.clips.map((currentClip) =>
currentClip.id === clip.id
? {
...currentClip,
- file: mediaFile.file!,
+ file: loadFile,
source: {
type: 'lottie',
textCanvas: runtime.canvas,
diff --git a/src/version.ts b/src/version.ts
index 006041aae..b252247eb 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,6 +1,6 @@
// App version
// Format: MAJOR.MINOR.PATCH
-export const APP_VERSION = '1.6.7';
+export const APP_VERSION = '1.6.8';
export interface ChangelogNotice {
type: 'info' | 'warning' | 'success' | 'danger';
diff --git a/tests/unit/autosaveRecovery.test.ts b/tests/unit/autosaveRecovery.test.ts
new file mode 100644
index 000000000..617de2066
--- /dev/null
+++ b/tests/unit/autosaveRecovery.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from 'vitest';
+import {
+ shouldPreferAutosave,
+ shouldSkipEmptyProjectSave,
+} from '../../src/services/project/core/autosaveRecovery';
+import type { ProjectFile } from '../../src/services/project/types';
+
+function createComposition(id = 'comp-1', clips: ProjectFile['compositions'][number]['clips'] = []): ProjectFile['compositions'][number] {
+ return {
+ id,
+ name: id,
+ width: 1920,
+ height: 1080,
+ frameRate: 30,
+ duration: 60,
+ backgroundColor: '#000000',
+ folderId: null,
+ tracks: [],
+ clips,
+ markers: [],
+ };
+}
+
+function createProject(overrides: Partial = {}): ProjectFile {
+ return {
+ version: 1,
+ name: 'Recovery Test',
+ createdAt: '2026-04-30T12:00:00.000Z',
+ updatedAt: '2026-04-30T12:00:00.000Z',
+ settings: {
+ width: 1920,
+ height: 1080,
+ frameRate: 30,
+ sampleRate: 48000,
+ },
+ media: [],
+ compositions: [createComposition()],
+ folders: [],
+ activeCompositionId: 'comp-1',
+ openCompositionIds: ['comp-1'],
+ expandedFolderIds: [],
+ ...overrides,
+ };
+}
+
+describe('autosave recovery', () => {
+ it('prefers a newer autosave', () => {
+ const project = createProject({ updatedAt: '2026-04-30T12:00:00.000Z' });
+ const autosave = createProject({ updatedAt: '2026-04-30T12:01:00.000Z' });
+
+ expect(shouldPreferAutosave(project, autosave)).toBe(true);
+ });
+
+ it('prefers a meaningful autosave over a freshly overwritten empty project', () => {
+ const project = createProject({ updatedAt: '2026-05-01T12:55:38.332Z' });
+ const autosave = createProject({
+ updatedAt: '2026-04-30T14:45:20.310Z',
+ folders: [{ id: 'folder-1', name: 'Shots', parentId: null }],
+ });
+
+ expect(shouldPreferAutosave(project, autosave)).toBe(true);
+ });
+
+ it('keeps a non-empty project when the autosave is older', () => {
+ const project = createProject({
+ updatedAt: '2026-05-01T12:55:38.332Z',
+ folders: [{ id: 'folder-1', name: 'Current', parentId: null }],
+ });
+ const autosave = createProject({
+ updatedAt: '2026-04-30T14:45:20.310Z',
+ folders: [{ id: 'folder-2', name: 'Older', parentId: null }],
+ });
+
+ expect(shouldPreferAutosave(project, autosave)).toBe(false);
+ });
+
+ it('blocks saving an empty project over a meaningful autosave', () => {
+ const project = createProject({ updatedAt: '2026-05-01T13:11:48.000Z' });
+ const autosave = createProject({
+ updatedAt: '2026-04-30T14:45:20.310Z',
+ media: [{ id: 'media-1', name: 'clip.mp4' } as ProjectFile['media'][number]],
+ });
+
+ expect(shouldSkipEmptyProjectSave(project, autosave)).toBe(true);
+ });
+});
diff --git a/tests/unit/projectFileServiceNativeBackend.test.ts b/tests/unit/projectFileServiceNativeBackend.test.ts
new file mode 100644
index 000000000..42290a39e
--- /dev/null
+++ b/tests/unit/projectFileServiceNativeBackend.test.ts
@@ -0,0 +1,136 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => {
+ const nativeCore = {
+ createProjectAtPath: vi.fn(async () => true),
+ loadProject: vi.fn(async () => true),
+ restoreLastProject: vi.fn(async () => true),
+ isSupported: vi.fn(() => true),
+ getProjectPath: vi.fn(() => null),
+ getProjectData: vi.fn(() => null),
+ isProjectOpen: vi.fn(() => false),
+ hasUnsavedChanges: vi.fn(() => false),
+ markDirty: vi.fn(),
+ needsPermission: vi.fn(() => false),
+ getPendingProjectName: vi.fn(() => null),
+ requestPendingPermission: vi.fn(async () => false),
+ createProject: vi.fn(async () => true),
+ saveProject: vi.fn(async () => true),
+ closeProject: vi.fn(),
+ createBackup: vi.fn(async () => true),
+ renameProject: vi.fn(async () => true),
+ saveKeysFile: vi.fn(async () => undefined),
+ loadKeysFile: vi.fn(async () => false),
+ updateProjectData: vi.fn(),
+ updateMedia: vi.fn(),
+ updateCompositions: vi.fn(),
+ updateFolders: vi.fn(),
+ };
+
+ return {
+ nativeCore,
+ nativeClient: {
+ isConnected: vi.fn(() => false),
+ connect: vi.fn(async () => true),
+ hasFsCommands: vi.fn(async () => true),
+ getProjectRoot: vi.fn(async () => 'C:\\Users\\tester\\Documents\\MasterSelects'),
+ grantPath: vi.fn(async () => true),
+ pickFolderDetailed: vi.fn(async () => ({
+ path: 'C:\\Projects',
+ cancelled: false,
+ })),
+ },
+ NativeProjectCoreService: vi.fn(function NativeProjectCoreService() {
+ return nativeCore;
+ }),
+ };
+});
+
+vi.mock('../../src/services/nativeHelper/NativeHelperClient', () => ({
+ NativeHelperClient: mocks.nativeClient,
+}));
+
+vi.mock('../../src/services/project/core/NativeProjectCoreService', () => ({
+ NativeProjectCoreService: mocks.NativeProjectCoreService,
+}));
+
+async function importFreshProjectFileService() {
+ vi.resetModules();
+ const mod = await import('../../src/services/project/ProjectFileService');
+ return mod.projectFileService;
+}
+
+function disableFileSystemAccessApi(): void {
+ delete (window as Window & { showDirectoryPicker?: unknown }).showDirectoryPicker;
+ delete (window as Window & { showSaveFilePicker?: unknown }).showSaveFilePicker;
+}
+
+describe('ProjectFileService native backend', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ disableFileSystemAccessApi();
+ mocks.nativeClient.isConnected.mockReturnValue(false);
+ mocks.nativeClient.connect.mockResolvedValue(true);
+ mocks.nativeClient.hasFsCommands.mockResolvedValue(true);
+ mocks.nativeClient.getProjectRoot.mockResolvedValue('C:\\Users\\tester\\Documents\\MasterSelects');
+ mocks.nativeClient.grantPath.mockResolvedValue(true);
+ mocks.nativeClient.pickFolderDetailed.mockResolvedValue({
+ path: 'C:\\Projects',
+ cancelled: false,
+ });
+ mocks.nativeCore.createProjectAtPath.mockResolvedValue(true);
+ mocks.nativeCore.loadProject.mockResolvedValue(true);
+ mocks.nativeCore.restoreLastProject.mockResolvedValue(true);
+ });
+
+ it('creates projects through the native backend when FSA is unavailable', async () => {
+ const projectFileService = await importFreshProjectFileService();
+
+ const created = await projectFileService.createProject('Firefox Project');
+
+ expect(created).toBe(true);
+ expect(mocks.nativeClient.connect).toHaveBeenCalledTimes(1);
+ expect(mocks.nativeClient.hasFsCommands).toHaveBeenCalledTimes(1);
+ expect(mocks.nativeClient.pickFolderDetailed).toHaveBeenCalledWith(
+ 'Choose where to save your project',
+ 'C:/Users/tester/Documents/MasterSelects',
+ );
+ expect(mocks.nativeCore.createProjectAtPath).toHaveBeenCalledWith(
+ 'C:/Projects',
+ 'Firefox Project',
+ );
+ expect(mocks.nativeClient.grantPath).toHaveBeenCalledWith('C:/Projects');
+ expect(projectFileService.activeBackend).toBe('native');
+ });
+
+ it('falls back to manual path entry when the native folder picker is unavailable', async () => {
+ const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('C:\\Manual\\Existing');
+ mocks.nativeClient.pickFolderDetailed.mockResolvedValue({
+ path: null,
+ cancelled: false,
+ error: 'Folder picker failed',
+ });
+ const projectFileService = await importFreshProjectFileService();
+
+ const opened = await projectFileService.openProject();
+
+ expect(opened).toBe(true);
+ expect(promptSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Enter the folder path manually'),
+ 'C:/Users/tester/Documents/MasterSelects',
+ );
+ expect(mocks.nativeCore.loadProject).toHaveBeenCalledWith('C:/Manual/Existing');
+ expect(mocks.nativeClient.grantPath).toHaveBeenCalledWith('C:/Manual/Existing');
+ });
+
+ it('restores the last project through the native backend on Firefox refresh', async () => {
+ const projectFileService = await importFreshProjectFileService();
+
+ const restored = await projectFileService.restoreLastProject();
+
+ expect(restored).toBe(true);
+ expect(mocks.nativeClient.connect).toHaveBeenCalledTimes(1);
+ expect(mocks.nativeCore.restoreLastProject).toHaveBeenCalledTimes(1);
+ expect(projectFileService.activeBackend).toBe('native');
+ });
+});
diff --git a/tools/native-helper/src/protocol/commands.rs b/tools/native-helper/src/protocol/commands.rs
index 0ea9eb861..47258b9a3 100644
--- a/tools/native-helper/src/protocol/commands.rs
+++ b/tools/native-helper/src/protocol/commands.rs
@@ -131,6 +131,12 @@ pub enum Command {
new_path: String,
},
+ /// Grant access to a path the browser already persisted from a user-picked project
+ GrantPath {
+ id: String,
+ path: String,
+ },
+
/// Open a native OS folder picker dialog
PickFolder {
id: String,
diff --git a/tools/native-helper/src/server.rs b/tools/native-helper/src/server.rs
index 922f9a5b8..397535672 100644
--- a/tools/native-helper/src/server.rs
+++ b/tools/native-helper/src/server.rs
@@ -189,20 +189,24 @@ async fn run_http_server(port: u16, state: Arc, allowed_origins: Arc>())
+ .and(with_state(state_for_file))
.and_then(serve_file);
// POST /upload?path=... — write binary body to file (AUTH REQUIRED)
+ let state_for_upload = state.clone();
let require_auth_upload = require_auth.clone();
let upload_route = warp::path("upload")
.and(warp::post())
.and(require_auth_upload)
.and(warp::query::>())
.and(warp::body::bytes())
+ .and(with_state(state_for_upload))
.and_then(handle_upload);
// GET /project-root — return the default project root path (NO AUTH - safe metadata)
@@ -421,6 +425,7 @@ fn guess_content_type(path: &std::path::Path) -> &'static str {
async fn serve_file(
params: std::collections::HashMap,
+ state: Arc,
) -> Result {
let path = params.get("path").ok_or_else(warp::reject::not_found)?;
let path = PathBuf::from(path);
@@ -429,7 +434,7 @@ async fn serve_file(
return Err(warp::reject::not_found());
}
- if !utils::is_path_allowed(&path) {
+ if !state.is_path_allowed(&path) {
warn!("HTTP: Rejected file request for: {}", path.display());
return Err(warp::reject::not_found());
}
@@ -453,6 +458,7 @@ async fn serve_file(
async fn handle_upload(
params: std::collections::HashMap,
body: warp::hyper::body::Bytes,
+ state: Arc,
) -> Result {
let path = params.get("path").ok_or_else(warp::reject::not_found)?;
let path = PathBuf::from(path);
@@ -462,7 +468,7 @@ async fn handle_upload(
return Err(warp::reject::not_found());
}
- if !utils::is_path_allowed(&path) {
+ if !state.is_path_allowed(&path) {
warn!("HTTP upload: Rejected path: {}", path.display());
return Err(warp::reject::not_found());
}
@@ -572,6 +578,7 @@ fn get_command_id(cmd: &Command) -> &str {
| Command::Delete { id, .. }
| Command::Exists { id, .. }
| Command::Rename { id, .. }
+ | Command::GrantPath { id, .. }
| Command::PickFolder { id, .. }
| Command::MatAnyoneStatus { id }
| Command::MatAnyoneSetup { id, .. }
diff --git a/tools/native-helper/src/session.rs b/tools/native-helper/src/session.rs
index 82b8e5cae..462b0c483 100644
--- a/tools/native-helper/src/session.rs
+++ b/tools/native-helper/src/session.rs
@@ -1,8 +1,8 @@
//! Per-connection session management
use std::collections::HashMap;
-use std::path::PathBuf;
-use std::sync::Arc;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, RwLock};
use tokio::sync::{oneshot, Mutex};
use tracing::{debug, info, warn};
@@ -98,6 +98,7 @@ pub struct AppState {
pub auth_token: Option,
editor_client: Mutex>,
pending_ai_requests: Mutex>>,
+ granted_paths: RwLock>,
pub matanyone_process: Mutex,
}
@@ -107,10 +108,28 @@ impl AppState {
auth_token,
editor_client: Mutex::new(None),
pending_ai_requests: Mutex::new(HashMap::new()),
+ granted_paths: RwLock::new(Vec::new()),
matanyone_process: Mutex::new(matanyone::process::MatAnyoneProcess::new()),
}
}
+ pub fn grant_path(&self, path: PathBuf) {
+ if !path.is_absolute() {
+ return;
+ }
+
+ let mut granted = self.granted_paths.write().unwrap_or_else(|e| e.into_inner());
+ if !granted.iter().any(|existing| existing == &path) {
+ info!("Granted file access root: {}", path.display());
+ granted.push(path);
+ }
+ }
+
+ pub fn is_path_allowed(&self, path: &Path) -> bool {
+ let granted = self.granted_paths.read().unwrap_or_else(|e| e.into_inner());
+ utils::is_path_allowed_with_extra(path, &granted)
+ }
+
pub async fn register_editor_client(&self, client: EditorClient) {
let mut editor = self.editor_client.lock().await;
*editor = Some(client);
@@ -232,6 +251,20 @@ impl Session {
Some(self.handle_rename(&id, &old_path, &new_path))
}
+ Command::GrantPath { id, path } => {
+ let path = PathBuf::from(path);
+ if !path.is_absolute() {
+ Some(Response::error(
+ &id,
+ error_codes::INVALID_PATH,
+ "Path must be absolute",
+ ))
+ } else {
+ self.state.grant_path(path);
+ Some(Response::ok(&id, serde_json::json!({ "granted": true })))
+ }
+ }
+
Command::PickFolder { id, title, default_path } => {
let title = title.unwrap_or_else(|| "Select folder".to_string());
let default_path = default_path.clone();
@@ -243,10 +276,13 @@ impl Session {
.await;
match result {
- Ok(Ok(Some(path))) => Some(Response::ok(
- &id,
- serde_json::json!({ "path": path.to_string_lossy() }),
- )),
+ Ok(Ok(Some(path))) => {
+ self.state.grant_path(path.clone());
+ Some(Response::ok(
+ &id,
+ serde_json::json!({ "path": path.to_string_lossy() }),
+ ))
+ }
Ok(Ok(None)) => Some(Response::ok(
&id,
serde_json::json!({ "path": serde_json::Value::Null, "cancelled": true }),
@@ -376,7 +412,7 @@ impl Session {
// within the helper's allowed directory policy.
for dir in extra_dirs {
let p = PathBuf::from(dir);
- if p.is_absolute() && p.is_dir() && utils::is_path_allowed(&p) {
+ if p.is_absolute() && p.is_dir() && self.state.is_path_allowed(&p) {
search_dirs.push(p);
}
}
@@ -472,7 +508,7 @@ impl Session {
return Response::error(id, error_codes::INVALID_PATH, "Path must be absolute");
}
- if !utils::is_path_allowed(path) {
+ if !self.state.is_path_allowed(path) {
return Response::error(
id,
error_codes::PERMISSION_DENIED,
@@ -518,7 +554,7 @@ impl Session {
return Response::error(id, error_codes::INVALID_PATH, "Path must be absolute");
}
- if !utils::is_path_allowed(path) {
+ if !self.state.is_path_allowed(path) {
return Response::error(id, error_codes::PERMISSION_DENIED, "Path not in allowed directory");
}
@@ -574,7 +610,7 @@ impl Session {
return Response::error(id, error_codes::INVALID_PATH, "Path must be absolute");
}
- if !utils::is_path_allowed(path) {
+ if !self.state.is_path_allowed(path) {
return Response::error(id, error_codes::PERMISSION_DENIED, "Path not in allowed directory");
}
@@ -607,7 +643,7 @@ impl Session {
return Response::error(id, error_codes::INVALID_PATH, "Path must be absolute");
}
- if !utils::is_path_allowed(path) {
+ if !self.state.is_path_allowed(path) {
return Response::error(id, error_codes::PERMISSION_DENIED, "Path not in allowed directory");
}
@@ -651,7 +687,7 @@ impl Session {
return Response::error(id, error_codes::INVALID_PATH, "Path must be absolute");
}
- if !utils::is_path_allowed(path) {
+ if !self.state.is_path_allowed(path) {
return Response::error(id, error_codes::PERMISSION_DENIED, "Path not in allowed directory");
}
@@ -692,7 +728,7 @@ impl Session {
return Response::error(id, error_codes::INVALID_PATH, "Path must be absolute");
}
- if !utils::is_path_allowed(path) {
+ if !self.state.is_path_allowed(path) {
return Response::error(id, error_codes::PERMISSION_DENIED, "Path not in allowed directory");
}
@@ -798,7 +834,7 @@ impl Session {
return Response::error(id, error_codes::INVALID_PATH, "Paths must be absolute");
}
- if !utils::is_path_allowed(old) || !utils::is_path_allowed(new) {
+ if !self.state.is_path_allowed(old) || !self.state.is_path_allowed(new) {
return Response::error(id, error_codes::PERMISSION_DENIED, "Path not in allowed directory");
}
diff --git a/tools/native-helper/src/utils.rs b/tools/native-helper/src/utils.rs
index 91c13f478..db1017463 100644
--- a/tools/native-helper/src/utils.rs
+++ b/tools/native-helper/src/utils.rs
@@ -137,50 +137,55 @@ fn path_is_within_allowed_prefix(path: &Path, prefix: &Path) -> bool {
.all(|(path_component, prefix_component)| path_component == prefix_component)
}
+fn canonicalize_existing_ancestor(path: &Path) -> Option {
+ if let Ok(canonical) = path.canonicalize() {
+ return Some(canonical);
+ }
+
+ for ancestor in path.ancestors().skip(1) {
+ if let Ok(canonical_ancestor) = ancestor.canonicalize() {
+ let suffix = path.strip_prefix(ancestor).ok()?;
+ return Some(canonical_ancestor.join(suffix));
+ }
+ }
+
+ None
+}
+
/// Check if a path is within allowed directories.
///
/// Rejects paths with `..` traversal segments and attempts path canonicalization
-/// to prevent symlink or alias-based escapes. Fails closed: if canonicalization
-/// fails and the path doesn't exist, the path is rejected.
-pub fn is_path_allowed(path: &std::path::Path) -> bool {
+/// to prevent symlink or alias-based escapes. For new files or directories, it
+/// canonicalizes the nearest existing ancestor and appends the missing suffix so
+/// project roots can be created lazily under approved parent folders.
+pub fn is_path_allowed_with_extra(path: &std::path::Path, extra_prefixes: &[PathBuf]) -> bool {
+ if !path.is_absolute() {
+ return false;
+ }
+
// Reject any path with traversal segments
if has_traversal_segments(path) {
return false;
}
- let allowed = get_allowed_prefixes();
-
- // Try to canonicalize the path for safer comparison.
- // If the path exists, use the canonical version.
- // If the path doesn't exist, use the raw path but only if it has no suspicious segments.
- let effective_path = match path.canonicalize() {
- Ok(canonical) => canonical,
- Err(_) => {
- // Path doesn't exist yet (e.g., writing a new file).
- // Check the parent directory instead if possible.
- if let Some(parent) = path.parent() {
- if let Ok(canonical_parent) = parent.canonicalize() {
- if let Some(file_name) = path.file_name() {
- canonical_parent.join(file_name)
- } else {
- return false; // No filename component
- }
- } else {
- // Neither path nor parent can be canonicalized — fail closed
- return false;
- }
- } else {
- return false; // No parent (root path or relative)
- }
- }
+ let mut allowed = get_allowed_prefixes();
+ allowed.extend(extra_prefixes.iter().cloned());
+
+ let effective_path = match canonicalize_existing_ancestor(path) {
+ Some(canonical) => canonical,
+ None => return false,
};
allowed.iter().any(|prefix| {
- let prefix_canonical = prefix.canonicalize().unwrap_or_else(|_| prefix.clone());
+ let prefix_canonical = canonicalize_existing_ancestor(prefix).unwrap_or_else(|| prefix.clone());
path_is_within_allowed_prefix(&effective_path, &prefix_canonical)
})
}
+pub fn is_path_allowed(path: &std::path::Path) -> bool {
+ is_path_allowed_with_extra(path, &[])
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -290,4 +295,37 @@ mod tests {
let _ = std::fs::remove_file(&sibling_path);
let _ = std::fs::remove_dir_all(&sibling_dir);
}
+
+ #[test]
+ fn test_allowed_nonexistent_project_dir_under_documents() {
+ if let Some(docs) = dirs::document_dir() {
+ let test_path = docs
+ .join(format!("masterselects-missing-{}", std::process::id()))
+ .join("Untitled")
+ .join("project.json");
+
+ assert!(!test_path.exists(), "test path should not already exist");
+ assert!(
+ is_path_allowed(&test_path),
+ "new project paths under Documents should be allowed before the project folder exists"
+ );
+ }
+ }
+
+ #[test]
+ fn test_allowed_by_extra_picked_root() {
+ if let Some(home) = dirs::home_dir() {
+ let picked_root = home.join(format!("masterselects-picked-{}", std::process::id()));
+ let test_path = picked_root.join("project.json");
+
+ assert!(
+ !is_path_allowed(&test_path),
+ "picked root should not be statically allowed before it is granted"
+ );
+ assert!(
+ is_path_allowed_with_extra(&test_path, &[picked_root]),
+ "paths under a user-picked root should be allowed"
+ );
+ }
+ }
}