Merge branch 'main' into use_sqlite_async_for_fetching_files_for_gallery
This commit is contained in:
commit
5879f5ed06
58 changed files with 1056 additions and 1075 deletions
|
@ -24,4 +24,5 @@ startup_notify: false
|
|||
# include:
|
||||
# - libcurl.so.4
|
||||
include:
|
||||
- libffi.so.7
|
||||
- libtiff.so.5
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
name: ente_auth
|
||||
description: ente two-factor authenticator
|
||||
version: 2.0.54+254
|
||||
version: 2.0.55+255
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -111,11 +111,11 @@ watcher for the watch folders functionality.
|
|||
|
||||
### AI/ML
|
||||
|
||||
- [onnxruntime-node](https://github.com/Microsoft/onnxruntime)
|
||||
- html-entities is used by the bundled clip-bpe-ts.
|
||||
- GGML binaries are bundled
|
||||
- We also use [jpeg-js](https://github.com/jpeg-js/jpeg-js#readme) for
|
||||
conversion of all images to JPEG before processing.
|
||||
- [onnxruntime-node](https://github.com/Microsoft/onnxruntime) is used for
|
||||
natural language searches based on CLIP.
|
||||
- html-entities is used by the bundled clip-bpe-ts tokenizer.
|
||||
- [jpeg-js](https://github.com/jpeg-js/jpeg-js#readme) is used for decoding
|
||||
JPEG data into raw RGB bytes before passing it to ONNX.
|
||||
|
||||
## ZIP
|
||||
|
||||
|
|
|
@ -19,7 +19,6 @@ mac:
|
|||
arch: [universal]
|
||||
category: public.app-category.photography
|
||||
hardenedRuntime: true
|
||||
x64ArchFiles: Contents/Resources/ggmlclip-mac
|
||||
afterSign: electron-builder-notarize
|
||||
extraFiles:
|
||||
- from: build
|
||||
|
|
|
@ -27,7 +27,7 @@ import { attachFSWatchIPCHandlers, attachIPCHandlers } from "./main/ipc";
|
|||
import log, { initLogging } from "./main/log";
|
||||
import { createApplicationMenu } from "./main/menu";
|
||||
import { isDev } from "./main/util";
|
||||
import { setupAutoUpdater } from "./services/appUpdater";
|
||||
import { setupAutoUpdater } from "./services/app-update";
|
||||
import { initWatcher } from "./services/chokidar";
|
||||
|
||||
let appIsQuitting = false;
|
||||
|
@ -142,9 +142,10 @@ const deleteLegacyDiskCacheDirIfExists = async () => {
|
|||
};
|
||||
|
||||
const attachEventHandlers = (mainWindow: BrowserWindow) => {
|
||||
// Let ipcRenderer know when mainWindow is in the foreground.
|
||||
// Let ipcRenderer know when mainWindow is in the foreground so that it can
|
||||
// in turn inform the renderer process.
|
||||
mainWindow.on("focus", () =>
|
||||
mainWindow.webContents.send("app-in-foreground"),
|
||||
mainWindow.webContents.send("mainWindowFocus"),
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -12,14 +12,11 @@ import type { FSWatcher } from "chokidar";
|
|||
import { ipcMain } from "electron/main";
|
||||
import {
|
||||
appVersion,
|
||||
muteUpdateNotification,
|
||||
skipAppUpdate,
|
||||
updateAndRestart,
|
||||
} from "../services/appUpdater";
|
||||
import {
|
||||
computeImageEmbedding,
|
||||
computeTextEmbedding,
|
||||
} from "../services/clipService";
|
||||
updateOnNextRestart,
|
||||
} from "../services/app-update";
|
||||
import { clipImageEmbedding, clipTextEmbedding } from "../services/clip";
|
||||
import { runFFmpegCmd } from "../services/ffmpeg";
|
||||
import { getDirFiles } from "../services/fs";
|
||||
import {
|
||||
|
@ -27,9 +24,9 @@ import {
|
|||
generateImageThumbnail,
|
||||
} from "../services/imageProcessor";
|
||||
import {
|
||||
clearElectronStore,
|
||||
getEncryptionKey,
|
||||
setEncryptionKey,
|
||||
clearStores,
|
||||
encryptionKey,
|
||||
saveEncryptionKey,
|
||||
} from "../services/store";
|
||||
import {
|
||||
getElectronFilesFromGoogleZip,
|
||||
|
@ -44,12 +41,7 @@ import {
|
|||
updateWatchMappingIgnoredFiles,
|
||||
updateWatchMappingSyncedFiles,
|
||||
} from "../services/watch";
|
||||
import type {
|
||||
ElectronFile,
|
||||
FILE_PATH_TYPE,
|
||||
Model,
|
||||
WatchMapping,
|
||||
} from "../types/ipc";
|
||||
import type { ElectronFile, FILE_PATH_TYPE, WatchMapping } from "../types/ipc";
|
||||
import {
|
||||
selectDirectory,
|
||||
showUploadDirsDialog,
|
||||
|
@ -103,26 +95,24 @@ export const attachIPCHandlers = () => {
|
|||
// See [Note: Catching exception during .send/.on]
|
||||
ipcMain.on("logToDisk", (_, message) => logToDisk(message));
|
||||
|
||||
ipcMain.on("clear-electron-store", () => {
|
||||
clearElectronStore();
|
||||
});
|
||||
ipcMain.on("clearStores", () => clearStores());
|
||||
|
||||
ipcMain.handle("setEncryptionKey", (_, encryptionKey) =>
|
||||
setEncryptionKey(encryptionKey),
|
||||
ipcMain.handle("saveEncryptionKey", (_, encryptionKey) =>
|
||||
saveEncryptionKey(encryptionKey),
|
||||
);
|
||||
|
||||
ipcMain.handle("getEncryptionKey", () => getEncryptionKey());
|
||||
ipcMain.handle("encryptionKey", () => encryptionKey());
|
||||
|
||||
// - App update
|
||||
|
||||
ipcMain.on("update-and-restart", () => updateAndRestart());
|
||||
ipcMain.on("updateAndRestart", () => updateAndRestart());
|
||||
|
||||
ipcMain.on("skip-app-update", (_, version) => skipAppUpdate(version));
|
||||
|
||||
ipcMain.on("mute-update-notification", (_, version) =>
|
||||
muteUpdateNotification(version),
|
||||
ipcMain.on("updateOnNextRestart", (_, version) =>
|
||||
updateOnNextRestart(version),
|
||||
);
|
||||
|
||||
ipcMain.on("skipAppUpdate", (_, version) => skipAppUpdate(version));
|
||||
|
||||
// - Conversion
|
||||
|
||||
ipcMain.handle("convertToJPEG", (_, fileData, filename) =>
|
||||
|
@ -148,14 +138,12 @@ export const attachIPCHandlers = () => {
|
|||
|
||||
// - ML
|
||||
|
||||
ipcMain.handle(
|
||||
"computeImageEmbedding",
|
||||
(_, model: Model, imageData: Uint8Array) =>
|
||||
computeImageEmbedding(model, imageData),
|
||||
ipcMain.handle("clipImageEmbedding", (_, jpegImageData: Uint8Array) =>
|
||||
clipImageEmbedding(jpegImageData),
|
||||
);
|
||||
|
||||
ipcMain.handle("computeTextEmbedding", (_, model: Model, text: string) =>
|
||||
computeTextEmbedding(model, text),
|
||||
ipcMain.handle("clipTextEmbedding", (_, text: string) =>
|
||||
clipTextEmbedding(text),
|
||||
);
|
||||
|
||||
// - File selection
|
||||
|
|
|
@ -19,6 +19,16 @@ export const initLogging = () => {
|
|||
log.transports.file.format = "[{y}-{m}-{d}T{h}:{i}:{s}{z}] {text}";
|
||||
|
||||
log.transports.console.level = false;
|
||||
|
||||
// Log unhandled errors and promise rejections.
|
||||
log.errorHandler.startCatching({
|
||||
onError: ({ error, errorName }) => {
|
||||
logError(errorName, error);
|
||||
// Prevent the default electron-log actions (e.g. showing a dialog)
|
||||
// from getting triggered.
|
||||
return false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -64,7 +74,10 @@ const logInfo = (...params: any[]) => {
|
|||
};
|
||||
|
||||
const logDebug = (param: () => any) => {
|
||||
if (isDev) console.log(`[debug] ${util.inspect(param())}`);
|
||||
if (isDev) {
|
||||
const p = param();
|
||||
console.log(`[debug] ${typeof p == "string" ? p : util.inspect(p)}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
shell,
|
||||
} from "electron";
|
||||
import { setIsAppQuitting } from "../main";
|
||||
import { forceCheckForUpdateAndNotify } from "../services/appUpdater";
|
||||
import { forceCheckForAppUpdates } from "../services/app-update";
|
||||
import autoLauncher from "../services/autoLauncher";
|
||||
import {
|
||||
getHideDockIconPreference,
|
||||
|
@ -26,8 +26,7 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
|
|||
const macOSOnly = (options: MenuItemConstructorOptions[]) =>
|
||||
process.platform == "darwin" ? options : [];
|
||||
|
||||
const handleCheckForUpdates = () =>
|
||||
forceCheckForUpdateAndNotify(mainWindow);
|
||||
const handleCheckForUpdates = () => forceCheckForAppUpdates(mainWindow);
|
||||
|
||||
const handleViewChangelog = () =>
|
||||
shell.openExternal(
|
||||
|
|
|
@ -45,7 +45,6 @@ import type {
|
|||
AppUpdateInfo,
|
||||
ElectronFile,
|
||||
FILE_PATH_TYPE,
|
||||
Model,
|
||||
WatchMapping,
|
||||
} from "./types/ipc";
|
||||
|
||||
|
@ -53,58 +52,55 @@ import type {
|
|||
|
||||
const appVersion = (): Promise<string> => ipcRenderer.invoke("appVersion");
|
||||
|
||||
const logToDisk = (message: string): void =>
|
||||
ipcRenderer.send("logToDisk", message);
|
||||
|
||||
const openDirectory = (dirPath: string): Promise<void> =>
|
||||
ipcRenderer.invoke("openDirectory", dirPath);
|
||||
|
||||
const openLogDirectory = (): Promise<void> =>
|
||||
ipcRenderer.invoke("openLogDirectory");
|
||||
|
||||
const logToDisk = (message: string): void =>
|
||||
ipcRenderer.send("logToDisk", message);
|
||||
const clearStores = () => ipcRenderer.send("clearStores");
|
||||
|
||||
const encryptionKey = (): Promise<string | undefined> =>
|
||||
ipcRenderer.invoke("encryptionKey");
|
||||
|
||||
const saveEncryptionKey = (encryptionKey: string): Promise<void> =>
|
||||
ipcRenderer.invoke("saveEncryptionKey", encryptionKey);
|
||||
|
||||
const onMainWindowFocus = (cb?: () => void) => {
|
||||
ipcRenderer.removeAllListeners("mainWindowFocus");
|
||||
if (cb) ipcRenderer.on("mainWindowFocus", cb);
|
||||
};
|
||||
|
||||
// - App update
|
||||
|
||||
const onAppUpdateAvailable = (
|
||||
cb?: ((updateInfo: AppUpdateInfo) => void) | undefined,
|
||||
) => {
|
||||
ipcRenderer.removeAllListeners("appUpdateAvailable");
|
||||
if (cb) {
|
||||
ipcRenderer.on("appUpdateAvailable", (_, updateInfo: AppUpdateInfo) =>
|
||||
cb(updateInfo),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const updateAndRestart = () => ipcRenderer.send("updateAndRestart");
|
||||
|
||||
const updateOnNextRestart = (version: string) =>
|
||||
ipcRenderer.send("updateOnNextRestart", version);
|
||||
|
||||
const skipAppUpdate = (version: string) => {
|
||||
ipcRenderer.send("skipAppUpdate", version);
|
||||
};
|
||||
|
||||
const fsExists = (path: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke("fsExists", path);
|
||||
|
||||
// - AUDIT below this
|
||||
|
||||
const registerForegroundEventListener = (onForeground: () => void) => {
|
||||
ipcRenderer.removeAllListeners("app-in-foreground");
|
||||
ipcRenderer.on("app-in-foreground", onForeground);
|
||||
};
|
||||
|
||||
const clearElectronStore = () => {
|
||||
ipcRenderer.send("clear-electron-store");
|
||||
};
|
||||
|
||||
const setEncryptionKey = (encryptionKey: string): Promise<void> =>
|
||||
ipcRenderer.invoke("setEncryptionKey", encryptionKey);
|
||||
|
||||
const getEncryptionKey = (): Promise<string> =>
|
||||
ipcRenderer.invoke("getEncryptionKey");
|
||||
|
||||
// - App update
|
||||
|
||||
const registerUpdateEventListener = (
|
||||
showUpdateDialog: (updateInfo: AppUpdateInfo) => void,
|
||||
) => {
|
||||
ipcRenderer.removeAllListeners("show-update-dialog");
|
||||
ipcRenderer.on("show-update-dialog", (_, updateInfo: AppUpdateInfo) => {
|
||||
showUpdateDialog(updateInfo);
|
||||
});
|
||||
};
|
||||
|
||||
const updateAndRestart = () => {
|
||||
ipcRenderer.send("update-and-restart");
|
||||
};
|
||||
|
||||
const skipAppUpdate = (version: string) => {
|
||||
ipcRenderer.send("skip-app-update", version);
|
||||
};
|
||||
|
||||
const muteUpdateNotification = (version: string) => {
|
||||
ipcRenderer.send("mute-update-notification", version);
|
||||
};
|
||||
|
||||
// - Conversion
|
||||
|
||||
const convertToJPEG = (
|
||||
|
@ -141,17 +137,11 @@ const runFFmpegCmd = (
|
|||
|
||||
// - ML
|
||||
|
||||
const computeImageEmbedding = (
|
||||
model: Model,
|
||||
imageData: Uint8Array,
|
||||
): Promise<Float32Array> =>
|
||||
ipcRenderer.invoke("computeImageEmbedding", model, imageData);
|
||||
const clipImageEmbedding = (jpegImageData: Uint8Array): Promise<Float32Array> =>
|
||||
ipcRenderer.invoke("clipImageEmbedding", jpegImageData);
|
||||
|
||||
const computeTextEmbedding = (
|
||||
model: Model,
|
||||
text: string,
|
||||
): Promise<Float32Array> =>
|
||||
ipcRenderer.invoke("computeTextEmbedding", model, text);
|
||||
const clipTextEmbedding = (text: string): Promise<Float32Array> =>
|
||||
ipcRenderer.invoke("clipTextEmbedding", text);
|
||||
|
||||
// - File selection
|
||||
|
||||
|
@ -310,21 +300,19 @@ const getDirFiles = (dirPath: string): Promise<ElectronFile[]> =>
|
|||
contextBridge.exposeInMainWorld("electron", {
|
||||
// - General
|
||||
appVersion,
|
||||
openDirectory,
|
||||
registerForegroundEventListener,
|
||||
clearElectronStore,
|
||||
getEncryptionKey,
|
||||
setEncryptionKey,
|
||||
|
||||
// - Logging
|
||||
openLogDirectory,
|
||||
logToDisk,
|
||||
openDirectory,
|
||||
openLogDirectory,
|
||||
clearStores,
|
||||
encryptionKey,
|
||||
saveEncryptionKey,
|
||||
onMainWindowFocus,
|
||||
|
||||
// - App update
|
||||
onAppUpdateAvailable,
|
||||
updateAndRestart,
|
||||
updateOnNextRestart,
|
||||
skipAppUpdate,
|
||||
muteUpdateNotification,
|
||||
registerUpdateEventListener,
|
||||
|
||||
// - Conversion
|
||||
convertToJPEG,
|
||||
|
@ -332,8 +320,8 @@ contextBridge.exposeInMainWorld("electron", {
|
|||
runFFmpegCmd,
|
||||
|
||||
// - ML
|
||||
computeImageEmbedding,
|
||||
computeTextEmbedding,
|
||||
clipImageEmbedding,
|
||||
clipTextEmbedding,
|
||||
|
||||
// - File selection
|
||||
selectDirectory,
|
||||
|
|
98
desktop/src/services/app-update.ts
Normal file
98
desktop/src/services/app-update.ts
Normal file
|
@ -0,0 +1,98 @@
|
|||
import { compareVersions } from "compare-versions";
|
||||
import { app, BrowserWindow } from "electron";
|
||||
import { default as electronLog } from "electron-log";
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { setIsAppQuitting, setIsUpdateAvailable } from "../main";
|
||||
import log from "../main/log";
|
||||
import { userPreferencesStore } from "../stores/user-preferences";
|
||||
import { AppUpdateInfo } from "../types/ipc";
|
||||
|
||||
export const setupAutoUpdater = (mainWindow: BrowserWindow) => {
|
||||
autoUpdater.logger = electronLog;
|
||||
autoUpdater.autoDownload = false;
|
||||
|
||||
const oneDay = 1 * 24 * 60 * 60 * 1000;
|
||||
setInterval(() => checkForUpdatesAndNotify(mainWindow), oneDay);
|
||||
checkForUpdatesAndNotify(mainWindow);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check for app update check ignoring any previously saved skips / mutes.
|
||||
*/
|
||||
export const forceCheckForAppUpdates = (mainWindow: BrowserWindow) => {
|
||||
userPreferencesStore.delete("skipAppVersion");
|
||||
userPreferencesStore.delete("muteUpdateNotificationVersion");
|
||||
checkForUpdatesAndNotify(mainWindow);
|
||||
};
|
||||
|
||||
const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => {
|
||||
try {
|
||||
const { updateInfo } = await autoUpdater.checkForUpdates();
|
||||
const { version } = updateInfo;
|
||||
|
||||
log.debug(() => `Checking for updates found version ${version}`);
|
||||
|
||||
if (compareVersions(version, app.getVersion()) <= 0) {
|
||||
log.debug(() => "Skipping update, already at latest version");
|
||||
return;
|
||||
}
|
||||
|
||||
if (version === userPreferencesStore.get("skipAppVersion")) {
|
||||
log.info(`User chose to skip version ${version}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const mutedVersion = userPreferencesStore.get(
|
||||
"muteUpdateNotificationVersion",
|
||||
);
|
||||
if (version === mutedVersion) {
|
||||
log.info(
|
||||
`User has muted update notifications for version ${version}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const showUpdateDialog = (updateInfo: AppUpdateInfo) =>
|
||||
mainWindow.webContents.send("appUpdateAvailable", updateInfo);
|
||||
|
||||
log.debug(() => "Attempting auto update");
|
||||
autoUpdater.downloadUpdate();
|
||||
|
||||
let timeout: NodeJS.Timeout;
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
autoUpdater.on("update-downloaded", () => {
|
||||
timeout = setTimeout(
|
||||
() => showUpdateDialog({ autoUpdatable: true, version }),
|
||||
fiveMinutes,
|
||||
);
|
||||
});
|
||||
autoUpdater.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
log.error("Auto update failed", error);
|
||||
showUpdateDialog({ autoUpdatable: false, version });
|
||||
});
|
||||
|
||||
setIsUpdateAvailable(true);
|
||||
} catch (e) {
|
||||
log.error("checkForUpdateAndNotify failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the version of the desktop app
|
||||
*
|
||||
* The return value is of the form `v1.2.3`.
|
||||
*/
|
||||
export const appVersion = () => `v${app.getVersion()}`;
|
||||
|
||||
export const updateAndRestart = () => {
|
||||
log.info("Restarting the app to apply update");
|
||||
setIsAppQuitting(true);
|
||||
autoUpdater.quitAndInstall();
|
||||
};
|
||||
|
||||
export const updateOnNextRestart = (version: string) =>
|
||||
userPreferencesStore.set("muteUpdateNotificationVersion", version);
|
||||
|
||||
export const skipAppUpdate = (version: string) =>
|
||||
userPreferencesStore.set("skipAppVersion", version);
|
|
@ -1,120 +0,0 @@
|
|||
import { compareVersions } from "compare-versions";
|
||||
import { app, BrowserWindow } from "electron";
|
||||
import { default as electronLog } from "electron-log";
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { setIsAppQuitting, setIsUpdateAvailable } from "../main";
|
||||
import log from "../main/log";
|
||||
import { AppUpdateInfo } from "../types/ipc";
|
||||
import {
|
||||
clearMuteUpdateNotificationVersion,
|
||||
clearSkipAppVersion,
|
||||
getMuteUpdateNotificationVersion,
|
||||
getSkipAppVersion,
|
||||
setMuteUpdateNotificationVersion,
|
||||
setSkipAppVersion,
|
||||
} from "./userPreference";
|
||||
|
||||
const FIVE_MIN_IN_MICROSECOND = 5 * 60 * 1000;
|
||||
const ONE_DAY_IN_MICROSECOND = 1 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function setupAutoUpdater(mainWindow: BrowserWindow) {
|
||||
autoUpdater.logger = electronLog;
|
||||
autoUpdater.autoDownload = false;
|
||||
checkForUpdateAndNotify(mainWindow);
|
||||
setInterval(
|
||||
() => checkForUpdateAndNotify(mainWindow),
|
||||
ONE_DAY_IN_MICROSECOND,
|
||||
);
|
||||
}
|
||||
|
||||
export function forceCheckForUpdateAndNotify(mainWindow: BrowserWindow) {
|
||||
try {
|
||||
clearSkipAppVersion();
|
||||
clearMuteUpdateNotificationVersion();
|
||||
checkForUpdateAndNotify(mainWindow);
|
||||
} catch (e) {
|
||||
log.error("forceCheckForUpdateAndNotify failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdateAndNotify(mainWindow: BrowserWindow) {
|
||||
try {
|
||||
log.debug(() => "checkForUpdateAndNotify");
|
||||
const { updateInfo } = await autoUpdater.checkForUpdates();
|
||||
log.debug(() => `Update version ${updateInfo.version}`);
|
||||
if (compareVersions(updateInfo.version, app.getVersion()) <= 0) {
|
||||
log.debug(() => "Skipping update, already at latest version");
|
||||
return;
|
||||
}
|
||||
const skipAppVersion = getSkipAppVersion();
|
||||
if (skipAppVersion && updateInfo.version === skipAppVersion) {
|
||||
log.info(`User chose to skip version ${updateInfo.version}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let timeout: NodeJS.Timeout;
|
||||
log.debug(() => "Attempting auto update");
|
||||
autoUpdater.downloadUpdate();
|
||||
const muteUpdateNotificationVersion =
|
||||
getMuteUpdateNotificationVersion();
|
||||
if (
|
||||
muteUpdateNotificationVersion &&
|
||||
updateInfo.version === muteUpdateNotificationVersion
|
||||
) {
|
||||
log.info(
|
||||
`User has muted update notifications for version ${updateInfo.version}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
autoUpdater.on("update-downloaded", () => {
|
||||
timeout = setTimeout(
|
||||
() =>
|
||||
showUpdateDialog(mainWindow, {
|
||||
autoUpdatable: true,
|
||||
version: updateInfo.version,
|
||||
}),
|
||||
FIVE_MIN_IN_MICROSECOND,
|
||||
);
|
||||
});
|
||||
autoUpdater.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
log.error("Auto update failed", error);
|
||||
showUpdateDialog(mainWindow, {
|
||||
autoUpdatable: false,
|
||||
version: updateInfo.version,
|
||||
});
|
||||
});
|
||||
|
||||
setIsUpdateAvailable(true);
|
||||
} catch (e) {
|
||||
log.error("checkForUpdateAndNotify failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateAndRestart() {
|
||||
log.info("user quit the app");
|
||||
setIsAppQuitting(true);
|
||||
autoUpdater.quitAndInstall();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the version of the desktop app
|
||||
*
|
||||
* The return value is of the form `v1.2.3`.
|
||||
*/
|
||||
export const appVersion = () => `v${app.getVersion()}`;
|
||||
|
||||
export function skipAppUpdate(version: string) {
|
||||
setSkipAppVersion(version);
|
||||
}
|
||||
|
||||
export function muteUpdateNotification(version: string) {
|
||||
setMuteUpdateNotificationVersion(version);
|
||||
}
|
||||
|
||||
function showUpdateDialog(
|
||||
mainWindow: BrowserWindow,
|
||||
updateInfo: AppUpdateInfo,
|
||||
) {
|
||||
mainWindow.webContents.send("show-update-dialog", updateInfo);
|
||||
}
|
288
desktop/src/services/clip.ts
Normal file
288
desktop/src/services/clip.ts
Normal file
|
@ -0,0 +1,288 @@
|
|||
/**
|
||||
* @file Compute CLIP embeddings
|
||||
*
|
||||
* @see `web/apps/photos/src/services/clip-service.ts` for more details. This
|
||||
* file implements the Node.js implementation of the actual embedding
|
||||
* computation. By doing it in the Node.js layer, we can use the binary ONNX
|
||||
* runtimes which are 10-20x faster than the WASM based web ones.
|
||||
*
|
||||
* The embeddings are computed using ONNX runtime. The model itself is not
|
||||
* shipped with the app but is downloaded on demand.
|
||||
*/
|
||||
import { app, net } from "electron/main";
|
||||
import { existsSync } from "fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { writeStream } from "../main/fs";
|
||||
import log from "../main/log";
|
||||
import { CustomErrors } from "../types/ipc";
|
||||
import Tokenizer from "../utils/clip-bpe-ts/mod";
|
||||
import { generateTempFilePath } from "../utils/temp";
|
||||
import { deleteTempFile } from "./ffmpeg";
|
||||
const jpeg = require("jpeg-js");
|
||||
const ort = require("onnxruntime-node");
|
||||
|
||||
const textModelName = "clip-text-vit-32-uint8.onnx";
|
||||
const textModelByteSize = 64173509; // 61.2 MB
|
||||
|
||||
const imageModelName = "clip-image-vit-32-float32.onnx";
|
||||
const imageModelByteSize = 351468764; // 335.2 MB
|
||||
|
||||
/** Return the path where the given {@link modelName} is meant to be saved */
|
||||
const modelSavePath = (modelName: string) =>
|
||||
path.join(app.getPath("userData"), "models", modelName);
|
||||
|
||||
const downloadModel = async (saveLocation: string, name: string) => {
|
||||
// `mkdir -p` the directory where we want to save the model.
|
||||
const saveDir = path.dirname(saveLocation);
|
||||
await fs.mkdir(saveDir, { recursive: true });
|
||||
// Download
|
||||
log.info(`Downloading CLIP model from ${name}`);
|
||||
const url = `https://models.ente.io/${name}`;
|
||||
const res = await net.fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
|
||||
// Save
|
||||
await writeStream(saveLocation, res.body);
|
||||
log.info(`Downloaded CLIP model ${name}`);
|
||||
};
|
||||
|
||||
let activeImageModelDownload: Promise<void> | undefined;
|
||||
|
||||
const imageModelPathDownloadingIfNeeded = async () => {
|
||||
try {
|
||||
const modelPath = modelSavePath(imageModelName);
|
||||
if (activeImageModelDownload) {
|
||||
log.info("Waiting for CLIP image model download to finish");
|
||||
await activeImageModelDownload;
|
||||
} else {
|
||||
if (!existsSync(modelPath)) {
|
||||
log.info("CLIP image model not found, downloading");
|
||||
activeImageModelDownload = downloadModel(
|
||||
modelPath,
|
||||
imageModelName,
|
||||
);
|
||||
await activeImageModelDownload;
|
||||
} else {
|
||||
const localFileSize = (await fs.stat(modelPath)).size;
|
||||
if (localFileSize !== imageModelByteSize) {
|
||||
log.error(
|
||||
`CLIP image model size ${localFileSize} does not match the expected size, downloading again`,
|
||||
);
|
||||
activeImageModelDownload = downloadModel(
|
||||
modelPath,
|
||||
imageModelName,
|
||||
);
|
||||
await activeImageModelDownload;
|
||||
}
|
||||
}
|
||||
}
|
||||
return modelPath;
|
||||
} finally {
|
||||
activeImageModelDownload = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
let textModelDownloadInProgress = false;
|
||||
|
||||
const textModelPathDownloadingIfNeeded = async () => {
|
||||
if (textModelDownloadInProgress)
|
||||
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
|
||||
|
||||
const modelPath = modelSavePath(textModelName);
|
||||
if (!existsSync(modelPath)) {
|
||||
log.info("CLIP text model not found, downloading");
|
||||
textModelDownloadInProgress = true;
|
||||
downloadModel(modelPath, textModelName)
|
||||
.catch((e) => {
|
||||
// log but otherwise ignore
|
||||
log.error("CLIP text model download failed", e);
|
||||
})
|
||||
.finally(() => {
|
||||
textModelDownloadInProgress = false;
|
||||
});
|
||||
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
|
||||
} else {
|
||||
const localFileSize = (await fs.stat(modelPath)).size;
|
||||
if (localFileSize !== textModelByteSize) {
|
||||
log.error(
|
||||
`CLIP text model size ${localFileSize} does not match the expected size, downloading again`,
|
||||
);
|
||||
textModelDownloadInProgress = true;
|
||||
downloadModel(modelPath, textModelName)
|
||||
.catch((e) => {
|
||||
// log but otherwise ignore
|
||||
log.error("CLIP text model download failed", e);
|
||||
})
|
||||
.finally(() => {
|
||||
textModelDownloadInProgress = false;
|
||||
});
|
||||
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
|
||||
}
|
||||
}
|
||||
|
||||
return modelPath;
|
||||
};
|
||||
|
||||
const createInferenceSession = async (modelPath: string) => {
|
||||
return await ort.InferenceSession.create(modelPath, {
|
||||
intraOpNumThreads: 1,
|
||||
enableCpuMemArena: false,
|
||||
});
|
||||
};
|
||||
|
||||
let imageSessionPromise: Promise<any> | undefined;
|
||||
|
||||
const onnxImageSession = async () => {
|
||||
if (!imageSessionPromise) {
|
||||
imageSessionPromise = (async () => {
|
||||
const modelPath = await imageModelPathDownloadingIfNeeded();
|
||||
return createInferenceSession(modelPath);
|
||||
})();
|
||||
}
|
||||
return imageSessionPromise;
|
||||
};
|
||||
|
||||
let _textSession: any = null;
|
||||
|
||||
const onnxTextSession = async () => {
|
||||
if (!_textSession) {
|
||||
const modelPath = await textModelPathDownloadingIfNeeded();
|
||||
_textSession = await createInferenceSession(modelPath);
|
||||
}
|
||||
return _textSession;
|
||||
};
|
||||
|
||||
export const clipImageEmbedding = async (jpegImageData: Uint8Array) => {
|
||||
const tempFilePath = await generateTempFilePath("");
|
||||
const imageStream = new Response(jpegImageData.buffer).body;
|
||||
await writeStream(tempFilePath, imageStream);
|
||||
try {
|
||||
return await clipImageEmbedding_(tempFilePath);
|
||||
} finally {
|
||||
await deleteTempFile(tempFilePath);
|
||||
}
|
||||
};
|
||||
|
||||
const clipImageEmbedding_ = async (jpegFilePath: string) => {
|
||||
const imageSession = await onnxImageSession();
|
||||
const t1 = Date.now();
|
||||
const rgbData = await getRGBData(jpegFilePath);
|
||||
const feeds = {
|
||||
input: new ort.Tensor("float32", rgbData, [1, 3, 224, 224]),
|
||||
};
|
||||
const t2 = Date.now();
|
||||
const results = await imageSession.run(feeds);
|
||||
log.debug(
|
||||
() =>
|
||||
`CLIP image embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`,
|
||||
);
|
||||
const imageEmbedding = results["output"].data; // Float32Array
|
||||
return normalizeEmbedding(imageEmbedding);
|
||||
};
|
||||
|
||||
const getRGBData = async (jpegFilePath: string) => {
|
||||
const jpegData = await fs.readFile(jpegFilePath);
|
||||
const rawImageData = jpeg.decode(jpegData, {
|
||||
useTArray: true,
|
||||
formatAsRGBA: false,
|
||||
});
|
||||
|
||||
const nx: number = rawImageData.width;
|
||||
const ny: number = rawImageData.height;
|
||||
const inputImage: Uint8Array = rawImageData.data;
|
||||
|
||||
const nx2: number = 224;
|
||||
const ny2: number = 224;
|
||||
const totalSize: number = 3 * nx2 * ny2;
|
||||
|
||||
const result: number[] = Array(totalSize).fill(0);
|
||||
const scale: number = Math.max(nx, ny) / 224;
|
||||
|
||||
const nx3: number = Math.round(nx / scale);
|
||||
const ny3: number = Math.round(ny / scale);
|
||||
|
||||
const mean: number[] = [0.48145466, 0.4578275, 0.40821073];
|
||||
const std: number[] = [0.26862954, 0.26130258, 0.27577711];
|
||||
|
||||
for (let y = 0; y < ny3; y++) {
|
||||
for (let x = 0; x < nx3; x++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
// Linear interpolation
|
||||
const sx: number = (x + 0.5) * scale - 0.5;
|
||||
const sy: number = (y + 0.5) * scale - 0.5;
|
||||
|
||||
const x0: number = Math.max(0, Math.floor(sx));
|
||||
const y0: number = Math.max(0, Math.floor(sy));
|
||||
|
||||
const x1: number = Math.min(x0 + 1, nx - 1);
|
||||
const y1: number = Math.min(y0 + 1, ny - 1);
|
||||
|
||||
const dx: number = sx - x0;
|
||||
const dy: number = sy - y0;
|
||||
|
||||
const j00: number = 3 * (y0 * nx + x0) + c;
|
||||
const j01: number = 3 * (y0 * nx + x1) + c;
|
||||
const j10: number = 3 * (y1 * nx + x0) + c;
|
||||
const j11: number = 3 * (y1 * nx + x1) + c;
|
||||
|
||||
const v00: number = inputImage[j00];
|
||||
const v01: number = inputImage[j01];
|
||||
const v10: number = inputImage[j10];
|
||||
const v11: number = inputImage[j11];
|
||||
|
||||
const v0: number = v00 * (1 - dx) + v01 * dx;
|
||||
const v1: number = v10 * (1 - dx) + v11 * dx;
|
||||
|
||||
const v: number = v0 * (1 - dy) + v1 * dy;
|
||||
|
||||
const v2: number = Math.min(Math.max(Math.round(v), 0), 255);
|
||||
|
||||
// createTensorWithDataList is dumb compared to reshape and
|
||||
// hence has to be given with one channel after another
|
||||
const i: number = y * nx3 + x + (c % 3) * 224 * 224;
|
||||
|
||||
result[i] = (v2 / 255 - mean[c]) / std[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeEmbedding = (embedding: Float32Array) => {
|
||||
let normalization = 0;
|
||||
for (let index = 0; index < embedding.length; index++) {
|
||||
normalization += embedding[index] * embedding[index];
|
||||
}
|
||||
const sqrtNormalization = Math.sqrt(normalization);
|
||||
for (let index = 0; index < embedding.length; index++) {
|
||||
embedding[index] = embedding[index] / sqrtNormalization;
|
||||
}
|
||||
return embedding;
|
||||
};
|
||||
|
||||
let _tokenizer: Tokenizer = null;
|
||||
const getTokenizer = () => {
|
||||
if (!_tokenizer) {
|
||||
_tokenizer = new Tokenizer();
|
||||
}
|
||||
return _tokenizer;
|
||||
};
|
||||
|
||||
export const clipTextEmbedding = async (text: string) => {
|
||||
const imageSession = await onnxTextSession();
|
||||
const t1 = Date.now();
|
||||
const tokenizer = getTokenizer();
|
||||
const tokenizedText = Int32Array.from(tokenizer.encodeForCLIP(text));
|
||||
const feeds = {
|
||||
input: new ort.Tensor("int32", tokenizedText, [1, 77]),
|
||||
};
|
||||
const t2 = Date.now();
|
||||
const results = await imageSession.run(feeds);
|
||||
log.debug(
|
||||
() =>
|
||||
`CLIP text embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`,
|
||||
);
|
||||
const textEmbedding = results["output"].data;
|
||||
return normalizeEmbedding(textEmbedding);
|
||||
};
|
|
@ -1,463 +0,0 @@
|
|||
import { app, net } from "electron/main";
|
||||
import { existsSync } from "fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { writeStream } from "../main/fs";
|
||||
import log from "../main/log";
|
||||
import { execAsync, isDev } from "../main/util";
|
||||
import { CustomErrors, Model, isModel } from "../types/ipc";
|
||||
import Tokenizer from "../utils/clip-bpe-ts/mod";
|
||||
import { getPlatform } from "../utils/common/platform";
|
||||
import { generateTempFilePath } from "../utils/temp";
|
||||
import { deleteTempFile } from "./ffmpeg";
|
||||
const jpeg = require("jpeg-js");
|
||||
|
||||
const CLIP_MODEL_PATH_PLACEHOLDER = "CLIP_MODEL";
|
||||
const GGMLCLIP_PATH_PLACEHOLDER = "GGML_PATH";
|
||||
const INPUT_PATH_PLACEHOLDER = "INPUT";
|
||||
|
||||
const IMAGE_EMBEDDING_EXTRACT_CMD: string[] = [
|
||||
GGMLCLIP_PATH_PLACEHOLDER,
|
||||
"-mv",
|
||||
CLIP_MODEL_PATH_PLACEHOLDER,
|
||||
"--image",
|
||||
INPUT_PATH_PLACEHOLDER,
|
||||
];
|
||||
|
||||
const TEXT_EMBEDDING_EXTRACT_CMD: string[] = [
|
||||
GGMLCLIP_PATH_PLACEHOLDER,
|
||||
"-mt",
|
||||
CLIP_MODEL_PATH_PLACEHOLDER,
|
||||
"--text",
|
||||
INPUT_PATH_PLACEHOLDER,
|
||||
];
|
||||
const ort = require("onnxruntime-node");
|
||||
|
||||
const TEXT_MODEL_DOWNLOAD_URL = {
|
||||
ggml: "https://models.ente.io/clip-vit-base-patch32_ggml-text-model-f16.gguf",
|
||||
onnx: "https://models.ente.io/clip-text-vit-32-uint8.onnx",
|
||||
};
|
||||
const IMAGE_MODEL_DOWNLOAD_URL = {
|
||||
ggml: "https://models.ente.io/clip-vit-base-patch32_ggml-vision-model-f16.gguf",
|
||||
onnx: "https://models.ente.io/clip-image-vit-32-float32.onnx",
|
||||
};
|
||||
|
||||
const TEXT_MODEL_NAME = {
|
||||
ggml: "clip-vit-base-patch32_ggml-text-model-f16.gguf",
|
||||
onnx: "clip-text-vit-32-uint8.onnx",
|
||||
};
|
||||
const IMAGE_MODEL_NAME = {
|
||||
ggml: "clip-vit-base-patch32_ggml-vision-model-f16.gguf",
|
||||
onnx: "clip-image-vit-32-float32.onnx",
|
||||
};
|
||||
|
||||
const IMAGE_MODEL_SIZE_IN_BYTES = {
|
||||
ggml: 175957504, // 167.8 MB
|
||||
onnx: 351468764, // 335.2 MB
|
||||
};
|
||||
const TEXT_MODEL_SIZE_IN_BYTES = {
|
||||
ggml: 127853440, // 121.9 MB,
|
||||
onnx: 64173509, // 61.2 MB
|
||||
};
|
||||
|
||||
/** Return the path where the given {@link modelName} is meant to be saved */
|
||||
const getModelSavePath = (modelName: string) =>
|
||||
path.join(app.getPath("userData"), "models", modelName);
|
||||
|
||||
async function downloadModel(saveLocation: string, url: string) {
|
||||
// confirm that the save location exists
|
||||
const saveDir = path.dirname(saveLocation);
|
||||
await fs.mkdir(saveDir, { recursive: true });
|
||||
log.info("downloading clip model");
|
||||
const res = await net.fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
|
||||
await writeStream(saveLocation, res.body);
|
||||
log.info("clip model downloaded");
|
||||
}
|
||||
|
||||
let imageModelDownloadInProgress: Promise<void> = null;
|
||||
|
||||
const getClipImageModelPath = async (type: "ggml" | "onnx") => {
|
||||
try {
|
||||
const modelSavePath = getModelSavePath(IMAGE_MODEL_NAME[type]);
|
||||
if (imageModelDownloadInProgress) {
|
||||
log.info("waiting for image model download to finish");
|
||||
await imageModelDownloadInProgress;
|
||||
} else {
|
||||
if (!existsSync(modelSavePath)) {
|
||||
log.info("CLIP image model not found, downloading");
|
||||
imageModelDownloadInProgress = downloadModel(
|
||||
modelSavePath,
|
||||
IMAGE_MODEL_DOWNLOAD_URL[type],
|
||||
);
|
||||
await imageModelDownloadInProgress;
|
||||
} else {
|
||||
const localFileSize = (await fs.stat(modelSavePath)).size;
|
||||
if (localFileSize !== IMAGE_MODEL_SIZE_IN_BYTES[type]) {
|
||||
log.info(
|
||||
`CLIP image model size mismatch, downloading again got: ${localFileSize}`,
|
||||
);
|
||||
imageModelDownloadInProgress = downloadModel(
|
||||
modelSavePath,
|
||||
IMAGE_MODEL_DOWNLOAD_URL[type],
|
||||
);
|
||||
await imageModelDownloadInProgress;
|
||||
}
|
||||
}
|
||||
}
|
||||
return modelSavePath;
|
||||
} finally {
|
||||
imageModelDownloadInProgress = null;
|
||||
}
|
||||
};
|
||||
|
||||
let textModelDownloadInProgress: boolean = false;
|
||||
|
||||
const getClipTextModelPath = async (type: "ggml" | "onnx") => {
|
||||
const modelSavePath = getModelSavePath(TEXT_MODEL_NAME[type]);
|
||||
if (textModelDownloadInProgress) {
|
||||
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
|
||||
} else {
|
||||
if (!existsSync(modelSavePath)) {
|
||||
log.info("CLIP text model not found, downloading");
|
||||
textModelDownloadInProgress = true;
|
||||
downloadModel(modelSavePath, TEXT_MODEL_DOWNLOAD_URL[type])
|
||||
.catch((e) => {
|
||||
// log but otherwise ignore
|
||||
log.error("CLIP text model download failed", e);
|
||||
})
|
||||
.finally(() => {
|
||||
textModelDownloadInProgress = false;
|
||||
});
|
||||
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
|
||||
} else {
|
||||
const localFileSize = (await fs.stat(modelSavePath)).size;
|
||||
if (localFileSize !== TEXT_MODEL_SIZE_IN_BYTES[type]) {
|
||||
log.info(
|
||||
`CLIP text model size mismatch, downloading again got: ${localFileSize}`,
|
||||
);
|
||||
textModelDownloadInProgress = true;
|
||||
downloadModel(modelSavePath, TEXT_MODEL_DOWNLOAD_URL[type])
|
||||
.catch((e) => {
|
||||
// log but otherwise ignore
|
||||
log.error("CLIP text model download failed", e);
|
||||
})
|
||||
.finally(() => {
|
||||
textModelDownloadInProgress = false;
|
||||
});
|
||||
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
|
||||
}
|
||||
}
|
||||
}
|
||||
return modelSavePath;
|
||||
};
|
||||
|
||||
function getGGMLClipPath() {
|
||||
return isDev
|
||||
? path.join("./build", `ggmlclip-${getPlatform()}`)
|
||||
: path.join(process.resourcesPath, `ggmlclip-${getPlatform()}`);
|
||||
}
|
||||
|
||||
async function createOnnxSession(modelPath: string) {
|
||||
return await ort.InferenceSession.create(modelPath, {
|
||||
intraOpNumThreads: 1,
|
||||
enableCpuMemArena: false,
|
||||
});
|
||||
}
|
||||
|
||||
let onnxImageSessionPromise: Promise<any> = null;
|
||||
|
||||
async function getOnnxImageSession() {
|
||||
if (!onnxImageSessionPromise) {
|
||||
onnxImageSessionPromise = (async () => {
|
||||
const clipModelPath = await getClipImageModelPath("onnx");
|
||||
return createOnnxSession(clipModelPath);
|
||||
})();
|
||||
}
|
||||
return onnxImageSessionPromise;
|
||||
}
|
||||
|
||||
let onnxTextSession: any = null;
|
||||
|
||||
async function getOnnxTextSession() {
|
||||
if (!onnxTextSession) {
|
||||
const clipModelPath = await getClipTextModelPath("onnx");
|
||||
onnxTextSession = await createOnnxSession(clipModelPath);
|
||||
}
|
||||
return onnxTextSession;
|
||||
}
|
||||
|
||||
let tokenizer: Tokenizer = null;
|
||||
function getTokenizer() {
|
||||
if (!tokenizer) {
|
||||
tokenizer = new Tokenizer();
|
||||
}
|
||||
return tokenizer;
|
||||
}
|
||||
|
||||
export const computeImageEmbedding = async (
|
||||
model: Model,
|
||||
imageData: Uint8Array,
|
||||
): Promise<Float32Array> => {
|
||||
if (!isModel(model)) throw new Error(`Invalid CLIP model ${model}`);
|
||||
|
||||
let tempInputFilePath = null;
|
||||
try {
|
||||
tempInputFilePath = await generateTempFilePath("");
|
||||
const imageStream = new Response(imageData.buffer).body;
|
||||
await writeStream(tempInputFilePath, imageStream);
|
||||
const embedding = await computeImageEmbedding_(
|
||||
model,
|
||||
tempInputFilePath,
|
||||
);
|
||||
return embedding;
|
||||
} catch (err) {
|
||||
if (isExecError(err)) {
|
||||
const parsedExecError = parseExecError(err);
|
||||
throw Error(parsedExecError);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
if (tempInputFilePath) {
|
||||
await deleteTempFile(tempInputFilePath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isExecError = (err: any) => {
|
||||
return err.message.includes("Command failed:");
|
||||
};
|
||||
|
||||
const parseExecError = (err: any) => {
|
||||
const errMessage = err.message;
|
||||
if (errMessage.includes("Bad CPU type in executable")) {
|
||||
return CustomErrors.UNSUPPORTED_PLATFORM(
|
||||
process.platform,
|
||||
process.arch,
|
||||
);
|
||||
} else {
|
||||
return errMessage;
|
||||
}
|
||||
};
|
||||
|
||||
async function computeImageEmbedding_(
|
||||
model: Model,
|
||||
inputFilePath: string,
|
||||
): Promise<Float32Array> {
|
||||
if (!existsSync(inputFilePath)) {
|
||||
throw new Error("Invalid file path");
|
||||
}
|
||||
switch (model) {
|
||||
case "ggml-clip":
|
||||
return await computeGGMLImageEmbedding(inputFilePath);
|
||||
case "onnx-clip":
|
||||
return await computeONNXImageEmbedding(inputFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
const computeGGMLImageEmbedding = async (
|
||||
inputFilePath: string,
|
||||
): Promise<Float32Array> => {
|
||||
const clipModelPath = await getClipImageModelPath("ggml");
|
||||
const ggmlclipPath = getGGMLClipPath();
|
||||
const cmd = IMAGE_EMBEDDING_EXTRACT_CMD.map((cmdPart) => {
|
||||
if (cmdPart === GGMLCLIP_PATH_PLACEHOLDER) {
|
||||
return ggmlclipPath;
|
||||
} else if (cmdPart === CLIP_MODEL_PATH_PLACEHOLDER) {
|
||||
return clipModelPath;
|
||||
} else if (cmdPart === INPUT_PATH_PLACEHOLDER) {
|
||||
return inputFilePath;
|
||||
} else {
|
||||
return cmdPart;
|
||||
}
|
||||
});
|
||||
|
||||
const { stdout } = await execAsync(cmd);
|
||||
// parse stdout and return embedding
|
||||
// get the last line of stdout
|
||||
const lines = stdout.split("\n");
|
||||
const lastLine = lines[lines.length - 1];
|
||||
const embedding = JSON.parse(lastLine);
|
||||
const embeddingArray = new Float32Array(embedding);
|
||||
return embeddingArray;
|
||||
};
|
||||
|
||||
const computeONNXImageEmbedding = async (
|
||||
inputFilePath: string,
|
||||
): Promise<Float32Array> => {
|
||||
const imageSession = await getOnnxImageSession();
|
||||
const t1 = Date.now();
|
||||
const rgbData = await getRGBData(inputFilePath);
|
||||
const feeds = {
|
||||
input: new ort.Tensor("float32", rgbData, [1, 3, 224, 224]),
|
||||
};
|
||||
const t2 = Date.now();
|
||||
const results = await imageSession.run(feeds);
|
||||
log.info(
|
||||
`onnx image embedding time: ${Date.now() - t1} ms (prep:${
|
||||
t2 - t1
|
||||
} ms, extraction: ${Date.now() - t2} ms)`,
|
||||
);
|
||||
const imageEmbedding = results["output"].data; // Float32Array
|
||||
return normalizeEmbedding(imageEmbedding);
|
||||
};
|
||||
|
||||
async function getRGBData(inputFilePath: string) {
|
||||
const jpegData = await fs.readFile(inputFilePath);
|
||||
const rawImageData = jpeg.decode(jpegData, {
|
||||
useTArray: true,
|
||||
formatAsRGBA: false,
|
||||
});
|
||||
|
||||
const nx: number = rawImageData.width;
|
||||
const ny: number = rawImageData.height;
|
||||
const inputImage: Uint8Array = rawImageData.data;
|
||||
|
||||
const nx2: number = 224;
|
||||
const ny2: number = 224;
|
||||
const totalSize: number = 3 * nx2 * ny2;
|
||||
|
||||
const result: number[] = Array(totalSize).fill(0);
|
||||
const scale: number = Math.max(nx, ny) / 224;
|
||||
|
||||
const nx3: number = Math.round(nx / scale);
|
||||
const ny3: number = Math.round(ny / scale);
|
||||
|
||||
const mean: number[] = [0.48145466, 0.4578275, 0.40821073];
|
||||
const std: number[] = [0.26862954, 0.26130258, 0.27577711];
|
||||
|
||||
for (let y = 0; y < ny3; y++) {
|
||||
for (let x = 0; x < nx3; x++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
// linear interpolation
|
||||
const sx: number = (x + 0.5) * scale - 0.5;
|
||||
const sy: number = (y + 0.5) * scale - 0.5;
|
||||
|
||||
const x0: number = Math.max(0, Math.floor(sx));
|
||||
const y0: number = Math.max(0, Math.floor(sy));
|
||||
|
||||
const x1: number = Math.min(x0 + 1, nx - 1);
|
||||
const y1: number = Math.min(y0 + 1, ny - 1);
|
||||
|
||||
const dx: number = sx - x0;
|
||||
const dy: number = sy - y0;
|
||||
|
||||
const j00: number = 3 * (y0 * nx + x0) + c;
|
||||
const j01: number = 3 * (y0 * nx + x1) + c;
|
||||
const j10: number = 3 * (y1 * nx + x0) + c;
|
||||
const j11: number = 3 * (y1 * nx + x1) + c;
|
||||
|
||||
const v00: number = inputImage[j00];
|
||||
const v01: number = inputImage[j01];
|
||||
const v10: number = inputImage[j10];
|
||||
const v11: number = inputImage[j11];
|
||||
|
||||
const v0: number = v00 * (1 - dx) + v01 * dx;
|
||||
const v1: number = v10 * (1 - dx) + v11 * dx;
|
||||
|
||||
const v: number = v0 * (1 - dy) + v1 * dy;
|
||||
|
||||
const v2: number = Math.min(Math.max(Math.round(v), 0), 255);
|
||||
|
||||
// createTensorWithDataList is dump compared to reshape and hence has to be given with one channel after another
|
||||
const i: number = y * nx3 + x + (c % 3) * 224 * 224;
|
||||
|
||||
result[i] = (v2 / 255 - mean[c]) / std[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const normalizeEmbedding = (embedding: Float32Array) => {
|
||||
let normalization = 0;
|
||||
for (let index = 0; index < embedding.length; index++) {
|
||||
normalization += embedding[index] * embedding[index];
|
||||
}
|
||||
const sqrtNormalization = Math.sqrt(normalization);
|
||||
for (let index = 0; index < embedding.length; index++) {
|
||||
embedding[index] = embedding[index] / sqrtNormalization;
|
||||
}
|
||||
return embedding;
|
||||
};
|
||||
|
||||
export async function computeTextEmbedding(
|
||||
model: Model,
|
||||
text: string,
|
||||
): Promise<Float32Array> {
|
||||
if (!isModel(model)) throw new Error(`Invalid CLIP model ${model}`);
|
||||
|
||||
try {
|
||||
const embedding = computeTextEmbedding_(model, text);
|
||||
return embedding;
|
||||
} catch (err) {
|
||||
if (isExecError(err)) {
|
||||
const parsedExecError = parseExecError(err);
|
||||
throw Error(parsedExecError);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function computeTextEmbedding_(
|
||||
model: Model,
|
||||
text: string,
|
||||
): Promise<Float32Array> {
|
||||
switch (model) {
|
||||
case "ggml-clip":
|
||||
return await computeGGMLTextEmbedding(text);
|
||||
case "onnx-clip":
|
||||
return await computeONNXTextEmbedding(text);
|
||||
}
|
||||
}
|
||||
|
||||
export async function computeGGMLTextEmbedding(
|
||||
text: string,
|
||||
): Promise<Float32Array> {
|
||||
const clipModelPath = await getClipTextModelPath("ggml");
|
||||
const ggmlclipPath = getGGMLClipPath();
|
||||
const cmd = TEXT_EMBEDDING_EXTRACT_CMD.map((cmdPart) => {
|
||||
if (cmdPart === GGMLCLIP_PATH_PLACEHOLDER) {
|
||||
return ggmlclipPath;
|
||||
} else if (cmdPart === CLIP_MODEL_PATH_PLACEHOLDER) {
|
||||
return clipModelPath;
|
||||
} else if (cmdPart === INPUT_PATH_PLACEHOLDER) {
|
||||
return text;
|
||||
} else {
|
||||
return cmdPart;
|
||||
}
|
||||
});
|
||||
|
||||
const { stdout } = await execAsync(cmd);
|
||||
// parse stdout and return embedding
|
||||
// get the last line of stdout
|
||||
const lines = stdout.split("\n");
|
||||
const lastLine = lines[lines.length - 1];
|
||||
const embedding = JSON.parse(lastLine);
|
||||
const embeddingArray = new Float32Array(embedding);
|
||||
return embeddingArray;
|
||||
}
|
||||
|
||||
export async function computeONNXTextEmbedding(
|
||||
text: string,
|
||||
): Promise<Float32Array> {
|
||||
const imageSession = await getOnnxTextSession();
|
||||
const t1 = Date.now();
|
||||
const tokenizer = getTokenizer();
|
||||
const tokenizedText = Int32Array.from(tokenizer.encodeForCLIP(text));
|
||||
const feeds = {
|
||||
input: new ort.Tensor("int32", tokenizedText, [1, 77]),
|
||||
};
|
||||
const t2 = Date.now();
|
||||
const results = await imageSession.run(feeds);
|
||||
log.info(
|
||||
`onnx text embedding time: ${Date.now() - t1} ms (prep:${
|
||||
t2 - t1
|
||||
} ms, extraction: ${Date.now() - t2} ms)`,
|
||||
);
|
||||
const textEmbedding = results["output"].data; // Float32Array
|
||||
return normalizeEmbedding(textEmbedding);
|
||||
}
|
|
@ -4,23 +4,22 @@ import { safeStorageStore } from "../stores/safeStorage.store";
|
|||
import { uploadStatusStore } from "../stores/upload.store";
|
||||
import { watchStore } from "../stores/watch.store";
|
||||
|
||||
export const clearElectronStore = () => {
|
||||
export const clearStores = () => {
|
||||
uploadStatusStore.clear();
|
||||
keysStore.clear();
|
||||
safeStorageStore.clear();
|
||||
watchStore.clear();
|
||||
};
|
||||
|
||||
export async function setEncryptionKey(encryptionKey: string) {
|
||||
export const saveEncryptionKey = async (encryptionKey: string) => {
|
||||
const encryptedKey: Buffer = await safeStorage.encryptString(encryptionKey);
|
||||
const b64EncryptedKey = Buffer.from(encryptedKey).toString("base64");
|
||||
safeStorageStore.set("encryptionKey", b64EncryptedKey);
|
||||
}
|
||||
};
|
||||
|
||||
export async function getEncryptionKey(): Promise<string> {
|
||||
export const encryptionKey = async (): Promise<string | undefined> => {
|
||||
const b64EncryptedKey = safeStorageStore.get("encryptionKey");
|
||||
if (b64EncryptedKey) {
|
||||
const keyBuffer = Buffer.from(b64EncryptedKey, "base64");
|
||||
return await safeStorage.decryptString(keyBuffer);
|
||||
}
|
||||
}
|
||||
if (!b64EncryptedKey) return undefined;
|
||||
const keyBuffer = Buffer.from(b64EncryptedKey, "base64");
|
||||
return await safeStorage.decryptString(keyBuffer);
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { userPreferencesStore } from "../stores/userPreferences.store";
|
||||
import { userPreferencesStore } from "../stores/user-preferences";
|
||||
|
||||
export function getHideDockIconPreference() {
|
||||
return userPreferencesStore.get("hideDockIcon");
|
||||
|
@ -7,27 +7,3 @@ export function getHideDockIconPreference() {
|
|||
export function setHideDockIconPreference(shouldHideDockIcon: boolean) {
|
||||
userPreferencesStore.set("hideDockIcon", shouldHideDockIcon);
|
||||
}
|
||||
|
||||
export function getSkipAppVersion() {
|
||||
return userPreferencesStore.get("skipAppVersion");
|
||||
}
|
||||
|
||||
export function setSkipAppVersion(version: string) {
|
||||
userPreferencesStore.set("skipAppVersion", version);
|
||||
}
|
||||
|
||||
export function getMuteUpdateNotificationVersion() {
|
||||
return userPreferencesStore.get("muteUpdateNotificationVersion");
|
||||
}
|
||||
|
||||
export function setMuteUpdateNotificationVersion(version: string) {
|
||||
userPreferencesStore.set("muteUpdateNotificationVersion", version);
|
||||
}
|
||||
|
||||
export function clearSkipAppVersion() {
|
||||
userPreferencesStore.delete("skipAppVersion");
|
||||
}
|
||||
|
||||
export function clearMuteUpdateNotificationVersion() {
|
||||
userPreferencesStore.delete("muteUpdateNotificationVersion");
|
||||
}
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import Store, { Schema } from "electron-store";
|
||||
import type { UserPreferencesType } from "../types/main";
|
||||
|
||||
const userPreferencesSchema: Schema<UserPreferencesType> = {
|
||||
interface UserPreferencesSchema {
|
||||
hideDockIcon: boolean;
|
||||
skipAppVersion?: string;
|
||||
muteUpdateNotificationVersion?: string;
|
||||
}
|
||||
|
||||
const userPreferencesSchema: Schema<UserPreferencesSchema> = {
|
||||
hideDockIcon: {
|
||||
type: "boolean",
|
||||
},
|
|
@ -80,7 +80,3 @@ export interface AppUpdateInfo {
|
|||
autoUpdatable: boolean;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export type Model = "ggml-clip" | "onnx-clip";
|
||||
|
||||
export const isModel = (s: unknown) => s == "ggml-clip" || s == "onnx-clip";
|
||||
|
|
|
@ -29,9 +29,3 @@ export const FILE_PATH_KEYS: {
|
|||
export interface SafeStorageStoreType {
|
||||
encryptionKey: string;
|
||||
}
|
||||
|
||||
export interface UserPreferencesType {
|
||||
hideDockIcon: boolean;
|
||||
skipAppVersion: string;
|
||||
muteUpdateNotificationVersion: string;
|
||||
}
|
||||
|
|
|
@ -18,11 +18,6 @@ configure the endpoint the app should be connecting to.
|
|||
|
||||

|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> This is only supported by the Ente Auth app currently. We'll add this same
|
||||
> functionality to the Ente Photos app soon.
|
||||
|
||||
## CLI
|
||||
|
||||
> [!NOTE]
|
||||
|
|
|
@ -16,5 +16,6 @@ ExecStart=docker run --name nginx \
|
|||
-v /root/nginx/cert.pem:/etc/ssl/certs/cert.pem:ro \
|
||||
-v /root/nginx/key.pem:/etc/ssl/private/key.pem:ro \
|
||||
-v /root/nginx/conf.d:/etc/nginx/conf.d:ro \
|
||||
--log-opt max-size=1g \
|
||||
nginx
|
||||
ExecReload=docker exec nginx nginx -s reload
|
||||
|
|
36
mobile/fastlane/metadata/android/tr/full_description.txt
Normal file
36
mobile/fastlane/metadata/android/tr/full_description.txt
Normal file
|
@ -0,0 +1,36 @@
|
|||
ente, fotoğraflarınızı ve videolarınızı yedeklemek ve paylaşmak için basit bir uygulamadır.
|
||||
|
||||
Google Fotoğraflar'a gizlilik dostu bir alternatif arıyorsanız doğru yere geldiniz. Ente ile uçtan uca şifrelenmiş olarak (e2ee) saklanırlar. Bu, onları yalnızca sizin görebileceğiniz anlamına gelir.
|
||||
|
||||
Android, iOS, web ve masaüstünde açık kaynaklı uygulamalarımız var ve fotoğraflarınız bunların tümü arasında uçtan uca şifrelenmiş (e2ee) şekilde sorunsuz bir şekilde senkronize edilecek.
|
||||
|
||||
ente ayrıca, ente'de olmasalar bile albümlerinizi sevdiklerinizle paylaşmanızı da kolaylaştırır. Bir hesap veya uygulama olmadan bile albümünüzü görüntüleyebilecekleri ve albüme fotoğraf ekleyerek ortak çalışabilecekleri, herkese açık olarak görüntülenebilen bağlantıları paylaşabilirsiniz.
|
||||
|
||||
Şifrelenmiş verileriniz, Paris'teki bir serpinti sığınağı da dahil olmak üzere birden fazla yerde depolanır. Gelecek nesilleri ciddiye alıyor ve anılarınızın sizden daha uzun yaşamasını sağlamayı kolaylaştırıyoruz.
|
||||
|
||||
Şimdiye kadarki en güvenli fotoğraf uygulamasını yapmak için buradayız, gelin yolculuğumuza katılın!
|
||||
|
||||
✨ÖZELLİKLER
|
||||
- Orijinal kalitede yedekler, çünkü her piksel önemlidir
|
||||
- Aile planları, böylece depolama alanını ailenizle paylaşabilirsiniz
|
||||
- Seyahatten sonra fotoğrafları bir araya toplayabilmeniz için ortak albümler
|
||||
- Partnerinizin "Kamera" tıklamalarınızın keyfini çıkarmasını istemeniz durumunda paylaşılan klasörler
|
||||
- Parola ile korunabilen ve süresi dolacak şekilde ayarlanabilen albüm bağlantıları
|
||||
- Güvenli bir şekilde yedeklenmiş dosyaları kaldırarak alan boşaltma yeteneği
|
||||
- İnsan desteği, çünkü sen buna değersin
|
||||
- Açıklamalar, böylece anılarınıza başlık yazabilir ve onları kolayca bulabilirsiniz
|
||||
- Son rötuşları eklemek için görüntü düzenleyici
|
||||
- Favori, sakla ve anılarını yeniden yaşa, çünkü onlar değerlidir
|
||||
- Google, Apple, sabit diskiniz ve daha fazlasından tek tıkla içe aktarma
|
||||
- Koyu tema, çünkü fotoğraflarınız bu temada güzel görünüyor
|
||||
- 2FA, 3FA, biyometrik kimlik doğrulama
|
||||
- ve çok daha fazlası!
|
||||
|
||||
İZİNLER
|
||||
bir fotoğraf depolama sağlayıcısının amacına hizmet etmek için belirli izinlere yönelik taleplerde bulunulabilir; bu izinler burada incelenebilir: https://github.com/ente-io/ente/blob/f-droid/mobile/android/permissions.md
|
||||
|
||||
FİYATLANDIRMA
|
||||
Sonsuza kadar ücretsiz planlar sunmuyoruz, çünkü sürdürülebilir kalmamız ve zamanın testine dayanmamız bizim için önemli. Bunun yerine, ailenizle özgürce paylaşabileceğiniz uygun fiyatlı planlar sunuyoruz. Daha fazla bilgiyi ente.io adresinde bulabilirsiniz.
|
||||
|
||||
🙋DESTEK
|
||||
İnsan desteği sunmaktan gurur duyuyoruz. Ücretli müşterimiz iseniz team@ente.io adresine ulaşabilir ve ekibimizden 24 saat içinde yanıt bekleyebilirsiniz.
|
|
@ -0,0 +1 @@
|
|||
ente uçtan uca şifrelenmiş bir fotoğraf depolama uygulamasıdır
|
1
mobile/fastlane/metadata/android/tr/title.txt
Normal file
1
mobile/fastlane/metadata/android/tr/title.txt
Normal file
|
@ -0,0 +1 @@
|
|||
ente - şifrelenmiş depolama sistemi
|
|
@ -1 +1 @@
|
|||
ente Фото
|
||||
ente фотографии
|
||||
|
|
|
@ -1 +1 @@
|
|||
Система зашифрованного хранения фотографий
|
||||
Зашифрованное хранилище фотографий
|
||||
|
|
33
mobile/fastlane/metadata/ios/tr/description.txt
Normal file
33
mobile/fastlane/metadata/ios/tr/description.txt
Normal file
|
@ -0,0 +1,33 @@
|
|||
Ente, fotoğraflarınızı ve videolarınızı yedekleyip paylaşmanızı sağlayan kullanimi kolay bir uygulamadır.
|
||||
|
||||
Anılarınızı saklamak için gizlilik dostu bir alternatif arıyorsanız, doğru yere geldiniz. Ente ile uçtan uca şifrelenmiş olarak (e2ee) saklanırlar. Bu, onları yalnızca sizin görebileceğiniz anlamına gelir.
|
||||
|
||||
Android, iOS, web ve Masaüstünde uygulamalarımız var ve fotoğraflarınız tüm cihazlarınız arasında uçtan uca şifrelenmiş (e2ee) bir şekilde sorunsuz bir şekilde senkronize edilecek.
|
||||
|
||||
Ente, albümlerinizi sevdiklerinizle paylaşmanızı da kolaylaştırıyor. Bunları uçtan uca şifrelenmiş olarak doğrudan diğer Ente kullanıcılarıyla paylaşabilir veya herkese açık olarak görüntülenebilir bağlantılarla paylaşabilirsiniz.
|
||||
|
||||
Şifrelenmiş verileriniz, Paris'teki bir serpinti sığınağı da dahil olmak üzere birden fazla yerde depolanır. Gelecek nesilleri ciddiye alıyor ve anılarınızın sizden daha uzun yaşamasını sağlamayı kolaylaştırıyoruz.
|
||||
|
||||
Şimdiye kadarki en güvenli fotoğraf uygulamasını yapmak için buradayız, gelin yolculuğumuza katılın!
|
||||
|
||||
✨ÖZELLİKLER
|
||||
- Orijinal kalitede yedekler, çünkü her piksel önemlidir
|
||||
- Aile planları, böylece depolama alanını ailenizle paylaşabilirsiniz
|
||||
- Partnerinizin "Kamera" tıklamalarınızın keyfini çıkarmasını istemeniz durumunda paylaşılan klasörler
|
||||
- Parola ile korunabilen ve süresi dolacak şekilde ayarlanabilen albüm bağlantıları
|
||||
- Güvenli bir şekilde yedeklenmiş dosyaları kaldırarak alan boşaltma yeteneği
|
||||
- Son rötuşları eklemek için görüntü düzenleyici
|
||||
- Favori, sakla ve anılarını yeniden yaşa, çünkü onlar değerlidir
|
||||
- Tüm büyük depolama sağlayıcılarından tek tıklamayla içe aktarma
|
||||
- Koyu tema, çünkü fotoğraflarınız bu temada güzel görünüyor
|
||||
- 2FA, 3FA, biyometrik kimlik doğrulama
|
||||
- ve çok daha fazlası!
|
||||
|
||||
FİYATLANDIRMA
|
||||
Sonsuza kadar ücretsiz planlar sunmuyoruz, çünkü sürdürülebilir kalmamız ve zamanın testine dayanmamız bizim için önemli. Bunun yerine, ailenizle özgürce paylaşabileceğiniz uygun fiyatlı planlar sunuyoruz. Daha fazla bilgiyi ente.io adresinde bulabilirsiniz.
|
||||
|
||||
🙋DESTEK
|
||||
İnsan desteği sunmaktan gurur duyuyoruz. Ücretli müşterimiz iseniz team@ente.io adresine ulaşabilir ve ekibimizden 24 saat içinde yanıt bekleyebilirsiniz.
|
||||
|
||||
ŞARTLAR
|
||||
https://ente.io/terms
|
1
mobile/fastlane/metadata/ios/tr/keywords.txt
Normal file
1
mobile/fastlane/metadata/ios/tr/keywords.txt
Normal file
|
@ -0,0 +1 @@
|
|||
fotoğraflar,fotoğrafçılık,aile,gizlilik,bulut,yedekleme,videolar,fotoğraf,şifreleme,depolama,albüm,alternatif
|
1
mobile/fastlane/metadata/ios/tr/name.txt
Normal file
1
mobile/fastlane/metadata/ios/tr/name.txt
Normal file
|
@ -0,0 +1 @@
|
|||
ente fotoğraf uygulaması
|
1
mobile/fastlane/metadata/ios/tr/subtitle.txt
Normal file
1
mobile/fastlane/metadata/ios/tr/subtitle.txt
Normal file
|
@ -0,0 +1 @@
|
|||
Şifrelenmiş depolama sistemi
|
30
mobile/fastlane/metadata/playstore/tr/full_description.txt
Normal file
30
mobile/fastlane/metadata/playstore/tr/full_description.txt
Normal file
|
@ -0,0 +1,30 @@
|
|||
Ente, fotoğraflarınızı ve videolarınızı yedekleyip paylaşmanızı sağlayan kullanimi kolay bir uygulamadır.
|
||||
|
||||
Anılarınızı saklamak için gizlilik dostu bir alternatif arıyorsanız, doğru yere geldiniz. Ente ile uçtan uca şifrelenmiş olarak (e2ee) saklanırlar. Bu, onları yalnızca sizin görüntüleyebileceğiniz anlamına gelir.
|
||||
|
||||
Android, iOS, web ve Masaüstünde uygulamalarımız var ve fotoğraflarınız tüm cihazlarınız arasında uçtan uca şifrelenmiş (e2ee) bir şekilde sorunsuz bir şekilde senkronize edilecek.
|
||||
|
||||
Ente, albümlerinizi sevdiklerinizle paylaşmanızı da kolaylaştırıyor. Bunları uçtan uca şifrelenmiş olarak doğrudan diğer Ente kullanıcılarıyla paylaşabilir veya herkese açık olarak görüntülenebilir bağlantılarla paylaşabilirsiniz.
|
||||
|
||||
Şifrelenmiş verileriniz, Paris'teki bir serpinti sığınağı da dahil olmak üzere birden fazla yerde depolanır. Gelecek nesilleri ciddiye alıyor ve anılarınızın sizden daha uzun yaşamasını sağlamayı kolaylaştırıyoruz.
|
||||
|
||||
Şimdiye kadarki en güvenli fotoğraf uygulamasını yapmak için buradayız, gelin yolculuğumuza katılın!
|
||||
|
||||
✨ÖZELLİKLER
|
||||
- Orijinal kalitede yedekler, çünkü her piksel önemlidir
|
||||
- Aile planları, böylece depolama alanını ailenizle paylaşabilirsiniz
|
||||
- Partnerinizin "Kamera" tıklamalarınızın keyfini çıkarmasını istemeniz durumunda paylaşılan klasörler
|
||||
- Parola ile korunabilen ve süresi dolacak şekilde ayarlanabilen albüm bağlantıları
|
||||
- Güvenli bir şekilde yedeklenmiş dosyaları kaldırarak alan boşaltma yeteneği
|
||||
- Son rötuşları eklemek için görüntü düzenleyici
|
||||
- Favori, sakla ve anılarını yeniden yaşa, çünkü onlar değerlidir
|
||||
- Google, Apple, sabit diskiniz ve daha fazlasından tek tıkla içe aktarma
|
||||
- Koyu tema, çünkü fotoğraflarınız bu temada güzel görünüyor
|
||||
- 2FA, 3FA, biyometrik kimlik doğrulama
|
||||
- ve çok daha fazlası!
|
||||
|
||||
💲 FİYATLANDIRMA
|
||||
Sonsuza kadar ücretsiz planlar sunmuyoruz, çünkü sürdürülebilir kalmamız ve zamanın testine dayanmamız bizim için önemli. Bunun yerine, ailenizle özgürce paylaşabileceğiniz uygun fiyatlı planlar sunuyoruz. Daha fazla bilgiyi ente.io adresinde bulabilirsiniz.
|
||||
|
||||
🙋DESTEK
|
||||
İnsan desteği sunmaktan gurur duyuyoruz. Ücretli müşterimiz iseniz team@ente.io adresine ulaşabilir ve ekibimizden 24 saat içinde yanıt bekleyebilirsiniz.
|
|
@ -0,0 +1 @@
|
|||
Şifreli fotoğraf depolama - fotoğraflarınızı ve videolarınızı yedekleyin, düzenleyin ve paylaşın
|
|
@ -225,7 +225,7 @@
|
|||
},
|
||||
"description": "Number of participants in an album, including the album owner."
|
||||
},
|
||||
"collabLinkSectionDescription": "Crie um link para permitir pessoas adicionar e ver fotos no seu álbum compartilhado sem a necessidade do aplicativo ou uma conta Ente. Ótimo para colecionar fotos de eventos.",
|
||||
"collabLinkSectionDescription": "Crie um link para permitir que as pessoas adicionem e vejam fotos no seu álbum compartilhado sem a necessidade do aplicativo ou uma conta Ente. Ótimo para colecionar fotos de eventos.",
|
||||
"collectPhotos": "Colete fotos",
|
||||
"collaborativeLink": "Link Colaborativo",
|
||||
"shareWithNonenteUsers": "Compartilhar com usuários não-Ente",
|
||||
|
@ -259,12 +259,12 @@
|
|||
},
|
||||
"verificationId": "ID de Verificação",
|
||||
"verifyEmailID": "Verificar {email}",
|
||||
"emailNoEnteAccount": "{email} Não possui uma conta Ente.\n\nEnvie um convite para compartilhar fotos.",
|
||||
"emailNoEnteAccount": "{email} não possui uma conta Ente.\n\nEnvie um convite para compartilhar fotos.",
|
||||
"shareMyVerificationID": "Aqui está meu ID de verificação para o Ente.io: {verificationID}",
|
||||
"shareTextConfirmOthersVerificationID": "Ei, você pode confirmar que este é seu ID de verificação do Ente.io? {verificationID}",
|
||||
"somethingWentWrong": "Algo deu errado",
|
||||
"sendInvite": "Enviar convite",
|
||||
"shareTextRecommendUsingEnte": "Baixe o Ente para podermos compartilhar facilmente fotos e vídeos de alta qualidade\n\nhttps://ente.io",
|
||||
"shareTextRecommendUsingEnte": "Baixe o Ente para que possamos compartilhar facilmente fotos e vídeos de qualidade original\n\nhttps://ente.io",
|
||||
"done": "Concluído",
|
||||
"applyCodeTitle": "Aplicar código",
|
||||
"enterCodeDescription": "Digite o código fornecido pelo seu amigo para reivindicar o armazenamento gratuito para vocês dois",
|
||||
|
@ -350,8 +350,8 @@
|
|||
"videoSmallCase": "Video",
|
||||
"photoSmallCase": "Foto",
|
||||
"singleFileDeleteHighlight": "Ele será excluído de todos os álbuns.",
|
||||
"singleFileInBothLocalAndRemote": "Este {fileType} está em ente e no seu dispositivo.",
|
||||
"singleFileInRemoteOnly": "Este {fileType} será excluído do ente.",
|
||||
"singleFileInBothLocalAndRemote": "Este {fileType} está tanto no Ente quanto no seu dispositivo.",
|
||||
"singleFileInRemoteOnly": "Este {fileType} será excluído do Ente.",
|
||||
"singleFileDeleteFromDevice": "Este {fileType} será excluído do seu dispositivo.",
|
||||
"deleteFromEnte": "Excluir do ente",
|
||||
"yesDelete": "Sim, excluir",
|
||||
|
@ -445,7 +445,7 @@
|
|||
"backupOverMobileData": "Backup de dados móveis",
|
||||
"backupVideos": "Backup de videos",
|
||||
"disableAutoLock": "Desativar bloqueio automático",
|
||||
"deviceLockExplanation": "Desative o bloqueio de tela do dispositivo quando o ente estiver em primeiro plano e houver um backup em andamento. Isso normalmente não é necessário, mas pode ajudar grandes uploads e importações iniciais de grandes bibliotecas a serem concluídos mais rapidamente.",
|
||||
"deviceLockExplanation": "Desative o bloqueio de tela do dispositivo quando o Ente estiver em primeiro plano e houver um backup em andamento. Isso normalmente não é necessário, mas pode ajudar nos envios grandes e importações iniciais de grandes bibliotecas a serem concluídos mais rapidamente.",
|
||||
"about": "Sobre",
|
||||
"weAreOpenSource": "Somos de código aberto!",
|
||||
"privacy": "Privacidade",
|
||||
|
@ -464,8 +464,8 @@
|
|||
"logout": "Encerrar sessão",
|
||||
"authToInitiateAccountDeletion": "Por favor, autentique-se para iniciar a exclusão de conta",
|
||||
"areYouSureYouWantToLogout": "Você tem certeza que deseja encerrar a sessão?",
|
||||
"yesLogout": "Sim, terminar sessão",
|
||||
"aNewVersionOfEnteIsAvailable": "Uma nova versão do ente está disponível.",
|
||||
"yesLogout": "Sim, encerrar sessão",
|
||||
"aNewVersionOfEnteIsAvailable": "Uma nova versão do Ente está disponível.",
|
||||
"update": "Atualização",
|
||||
"installManually": "Instalar manualmente",
|
||||
"criticalUpdateAvailable": "Atualização crítica disponível",
|
||||
|
@ -515,7 +515,7 @@
|
|||
}
|
||||
},
|
||||
"familyPlans": "Plano familiar",
|
||||
"referrals": "Indicações",
|
||||
"referrals": "Referências",
|
||||
"notifications": "Notificações",
|
||||
"sharedPhotoNotifications": "Novas fotos compartilhadas",
|
||||
"sharedPhotoNotificationsExplanation": "Receber notificações quando alguém adicionar uma foto em um álbum compartilhado que você faz parte",
|
||||
|
@ -554,11 +554,11 @@
|
|||
"systemTheme": "Sistema",
|
||||
"freeTrial": "Teste gratuito",
|
||||
"selectYourPlan": "Selecione seu plano",
|
||||
"enteSubscriptionPitch": "O ente preserva suas memórias, então eles estão sempre disponíveis para você, mesmo se você perder o seu dispositivo.",
|
||||
"enteSubscriptionPitch": "O Ente preserva suas memórias, então eles estão sempre disponíveis para você, mesmo se você perder o seu dispositivo.",
|
||||
"enteSubscriptionShareWithFamily": "Sua família também pode ser adicionada ao seu plano.",
|
||||
"currentUsageIs": "O uso atual é ",
|
||||
"@currentUsageIs": {
|
||||
"description": "This text is followed by storage usaged",
|
||||
"description": "This text is followed by storage usage",
|
||||
"examples": {
|
||||
"0": "Current usage is 1.2 GB"
|
||||
},
|
||||
|
@ -620,7 +620,7 @@
|
|||
"appleId": "ID da Apple",
|
||||
"playstoreSubscription": "Assinatura da PlayStore",
|
||||
"appstoreSubscription": "Assinatura da AppStore",
|
||||
"subAlreadyLinkedErrMessage": "Seu {id} já está vinculado a outra conta ente.\nSe você gostaria de usar seu {id} com esta conta, por favor contate nosso suporte''",
|
||||
"subAlreadyLinkedErrMessage": "Seu {id} já está vinculado a outra conta Ente.\nSe você gostaria de usar seu {id} com esta conta, por favor contate nosso suporte''",
|
||||
"visitWebToManage": "Por favor visite web.ente.io para gerenciar sua assinatura",
|
||||
"couldNotUpdateSubscription": "Não foi possível atualizar a assinatura",
|
||||
"pleaseContactSupportAndWeWillBeHappyToHelp": "Por favor, entre em contato com support@ente.io e nós ficaremos felizes em ajudar!",
|
||||
|
@ -665,9 +665,9 @@
|
|||
"everywhere": "em todos os lugares",
|
||||
"androidIosWebDesktop": "Android, iOS, Web, Desktop",
|
||||
"mobileWebDesktop": "Mobile, Web, Desktop",
|
||||
"newToEnte": "Novo no ente",
|
||||
"newToEnte": "Novo no Ente",
|
||||
"pleaseLoginAgain": "Por favor, faça login novamente",
|
||||
"devAccountChanged": "A conta de desenvolvedor que usamos para publicar o ente na App Store foi alterada. Por esse motivo, você precisará fazer login novamente.\n\nPedimos desculpas pelo inconveniente, mas isso era inevitável.",
|
||||
"devAccountChanged": "A conta de desenvolvedor que usamos para publicar o Ente na App Store foi alterada. Por esse motivo, você precisará fazer entrar novamente.\n\nPedimos desculpas pelo inconveniente, mas isso era inevitável.",
|
||||
"yourSubscriptionHasExpired": "A sua assinatura expirou",
|
||||
"storageLimitExceeded": "Limite de armazenamento excedido",
|
||||
"upgrade": "Aprimorar",
|
||||
|
@ -678,12 +678,12 @@
|
|||
},
|
||||
"backupFailed": "Erro ao efetuar o backup",
|
||||
"couldNotBackUpTryLater": "Não foi possível fazer o backup de seus dados.\nTentaremos novamente mais tarde.",
|
||||
"enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "ente pode criptografar e preservar arquivos somente se você conceder acesso a eles",
|
||||
"enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "Ente pode criptografar e preservar arquivos apenas se você conceder acesso a eles",
|
||||
"pleaseGrantPermissions": "Por favor, conceda as permissões",
|
||||
"grantPermission": "Garantir permissão",
|
||||
"privateSharing": "Compartilhamento privado",
|
||||
"shareOnlyWithThePeopleYouWant": "Compartilhar apenas com as pessoas que você quiser",
|
||||
"usePublicLinksForPeopleNotOnEnte": "Usar links públicos para pessoas que não estão no ente",
|
||||
"usePublicLinksForPeopleNotOnEnte": "Usar links públicos para pessoas que não estão no Ente",
|
||||
"allowPeopleToAddPhotos": "Permitir que pessoas adicionem fotos",
|
||||
"shareAnAlbumNow": "Compartilhar um álbum agora",
|
||||
"collectEventPhotos": "Coletar fotos do evento",
|
||||
|
@ -695,7 +695,7 @@
|
|||
},
|
||||
"onDevice": "No dispositivo",
|
||||
"@onEnte": {
|
||||
"description": "The text displayed above albums backed up to ente",
|
||||
"description": "The text displayed above albums backed up to Ente",
|
||||
"type": "text"
|
||||
},
|
||||
"onEnte": "Em <branding>ente</branding>",
|
||||
|
@ -741,9 +741,9 @@
|
|||
"saveCollage": "Salvar colagem",
|
||||
"collageSaved": "Colagem salva na galeria",
|
||||
"collageLayout": "Layout",
|
||||
"addToEnte": "Adicionar ao ente",
|
||||
"addToEnte": "Adicionar ao Ente",
|
||||
"addToAlbum": "Adicionar ao álbum",
|
||||
"delete": "Apagar",
|
||||
"delete": "Excluir",
|
||||
"hide": "Ocultar",
|
||||
"share": "Compartilhar",
|
||||
"unhideToAlbum": "Reexibir para o álbum",
|
||||
|
@ -806,10 +806,10 @@
|
|||
"photosAddedByYouWillBeRemovedFromTheAlbum": "As fotos adicionadas por você serão removidas do álbum",
|
||||
"youveNoFilesInThisAlbumThatCanBeDeleted": "Você não tem arquivos neste álbum que possam ser excluídos",
|
||||
"youDontHaveAnyArchivedItems": "Você não tem nenhum item arquivado.",
|
||||
"ignoredFolderUploadReason": "Alguns arquivos neste álbum são ignorados do upload porque eles tinham sido anteriormente excluídos do ente.",
|
||||
"ignoredFolderUploadReason": "Alguns arquivos neste álbum são ignorados do envio porque eles tinham sido anteriormente excluídos do Ente.",
|
||||
"resetIgnoredFiles": "Redefinir arquivos ignorados",
|
||||
"deviceFilesAutoUploading": "Arquivos adicionados a este álbum do dispositivo serão automaticamente enviados para o ente.",
|
||||
"turnOnBackupForAutoUpload": "Ative o backup para enviar automaticamente arquivos adicionados a esta pasta do dispositivo para o ente.",
|
||||
"deviceFilesAutoUploading": "Arquivos adicionados a este álbum do dispositivo serão automaticamente enviados para o Ente.",
|
||||
"turnOnBackupForAutoUpload": "Ative o backup para enviar automaticamente arquivos adicionados a esta pasta do dispositivo para o Ente.",
|
||||
"noHiddenPhotosOrVideos": "Nenhuma foto ou vídeos ocultos",
|
||||
"toHideAPhotoOrVideo": "Para ocultar uma foto ou vídeo",
|
||||
"openTheItem": "• Abra o item",
|
||||
|
@ -886,7 +886,7 @@
|
|||
"@freeUpSpaceSaving": {
|
||||
"description": "Text to tell user how much space they can free up by deleting items from the device"
|
||||
},
|
||||
"freeUpAccessPostDelete": "Você ainda pode acessar {count, plural, one {ele} other {eles}} no ente contanto que você tenha uma assinatura ativa",
|
||||
"freeUpAccessPostDelete": "Você ainda pode acessar {count, plural, one {ele} other {eles}} no Ente contanto que você tenha uma assinatura ativa",
|
||||
"@freeUpAccessPostDelete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
|
@ -937,7 +937,7 @@
|
|||
"renameFile": "Renomear arquivo",
|
||||
"enterFileName": "Digite o nome do arquivo",
|
||||
"filesDeleted": "Arquivos excluídos",
|
||||
"selectedFilesAreNotOnEnte": "Os arquivos selecionados não estão no ente",
|
||||
"selectedFilesAreNotOnEnte": "Os arquivos selecionados não estão no Ente",
|
||||
"thisActionCannotBeUndone": "Esta ação não pode ser desfeita",
|
||||
"emptyTrash": "Esvaziar a lixeira?",
|
||||
"permDeleteWarning": "Todos os itens na lixeira serão excluídos permanentemente\n\nEsta ação não pode ser desfeita",
|
||||
|
@ -946,7 +946,7 @@
|
|||
"permanentlyDeleteFromDevice": "Excluir permanentemente do dispositivo?",
|
||||
"someOfTheFilesYouAreTryingToDeleteAre": "Alguns dos arquivos que você está tentando excluir só estão disponíveis no seu dispositivo e não podem ser recuperados se forem excluídos",
|
||||
"theyWillBeDeletedFromAllAlbums": "Ele será excluído de todos os álbuns.",
|
||||
"someItemsAreInBothEnteAndYourDevice": "Alguns itens estão tanto no ente quanto no seu dispositivo.",
|
||||
"someItemsAreInBothEnteAndYourDevice": "Alguns itens estão tanto no Ente quanto no seu dispositivo.",
|
||||
"selectedItemsWillBeDeletedFromAllAlbumsAndMoved": "Os itens selecionados serão excluídos de todos os álbuns e movidos para o lixo.",
|
||||
"theseItemsWillBeDeletedFromYourDevice": "Estes itens serão excluídos do seu dispositivo.",
|
||||
"itLooksLikeSomethingWentWrongPleaseRetryAfterSome": "Parece que algo deu errado. Por favor, tente novamente mais tarde. Se o erro persistir, entre em contato com nossa equipe de suporte.",
|
||||
|
@ -1052,7 +1052,7 @@
|
|||
},
|
||||
"setRadius": "Definir raio",
|
||||
"familyPlanPortalTitle": "Família",
|
||||
"familyPlanOverview": "Adicione 5 membros da família ao seu plano existente sem pagar a mais.\n\nCada membro recebe seu próprio espaço privado, e nenhum membro pode ver os arquivos uns dos outros a menos que sejam compartilhados.\n\nPlanos de família estão disponíveis para os clientes que têm uma assinatura de ente paga.\n\nassine agora para começar!",
|
||||
"familyPlanOverview": "Adicione 5 membros da família ao seu plano existente sem pagar a mais.\n\nCada membro recebe seu próprio espaço privado, e nenhum membro pode ver os arquivos uns dos outros a menos que sejam compartilhados.\n\nPlanos de família estão disponíveis para os clientes que têm uma assinatura do Ente paga.\n\nAssine agora para começar!",
|
||||
"androidBiometricHint": "Verificar identidade",
|
||||
"@androidBiometricHint": {
|
||||
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."
|
||||
|
@ -1113,7 +1113,7 @@
|
|||
},
|
||||
"maps": "Mapas",
|
||||
"enableMaps": "Habilitar mapa",
|
||||
"enableMapsDesc": "Isto mostrará suas fotos em um mapa do mundo.\n\nEste mapa é hospedado pelo Open Street Map, e os exatos locais de suas fotos nunca são compartilhados.\n\nVocê pode desativar esse recurso a qualquer momento nas Configurações.",
|
||||
"enableMapsDesc": "Isto mostrará suas fotos em um mapa do mundo.\n\nEste mapa é hospedado pelo OpenStreetMap, e os exatos locais de suas fotos nunca são compartilhados.\n\nVocê pode desativar esse recurso a qualquer momento nas Configurações.",
|
||||
"quickLinks": "Links rápidos",
|
||||
"selectItemsToAdd": "Selecionar itens para adicionar",
|
||||
"addSelected": "Adicionar selecionado",
|
||||
|
@ -1130,7 +1130,7 @@
|
|||
"noAlbumsSharedByYouYet": "Nenhum álbum compartilhado por você ainda",
|
||||
"sharedWithYou": "Compartilhado com você",
|
||||
"sharedByYou": "Compartilhado por você",
|
||||
"inviteYourFriendsToEnte": "Convide seus amigos ao ente",
|
||||
"inviteYourFriendsToEnte": "Convide seus amigos ao Ente",
|
||||
"failedToDownloadVideo": "Falha ao baixar vídeo",
|
||||
"hiding": "Ocultando...",
|
||||
"unhiding": "Desocultando...",
|
||||
|
@ -1140,7 +1140,7 @@
|
|||
"addToHiddenAlbum": "Adicionar a álbum oculto",
|
||||
"moveToHiddenAlbum": "Mover para álbum oculto",
|
||||
"fileTypes": "Tipos de arquivo",
|
||||
"deleteConfirmDialogBody": "Esta conta está vinculada a outros aplicativos ente, se você usar algum. Seus dados enviados, em todos os aplicativos ente, serão agendados para exclusão, e sua conta será excluída permanentemente.",
|
||||
"deleteConfirmDialogBody": "Esta conta está vinculada a outros aplicativos Ente, se você usar algum. Seus dados enviados, em todos os aplicativos Ente, serão agendados para exclusão, e sua conta será excluída permanentemente.",
|
||||
"hearUsWhereTitle": "Como você ouviu sobre o Ente? (opcional)",
|
||||
"hearUsExplanation": "Não rastreamos instalações do aplicativo. Seria útil se você nos contasse onde nos encontrou!",
|
||||
"viewAddOnButton": "Ver complementos",
|
||||
|
@ -1178,9 +1178,9 @@
|
|||
"contacts": "Contatos",
|
||||
"noInternetConnection": "Sem conexão à internet",
|
||||
"pleaseCheckYourInternetConnectionAndTryAgain": "Verifique sua conexão com a internet e tente novamente.",
|
||||
"signOutFromOtherDevices": "Terminar sessão em outros dispositivos",
|
||||
"signOutFromOtherDevices": "Encerrar sessão em outros dispositivos",
|
||||
"signOutOtherBody": "Se você acha que alguém pode saber sua senha, você pode forçar todos os outros dispositivos que estão com sua conta a desconectar.",
|
||||
"signOutOtherDevices": "Terminar sessão em outros dispositivos",
|
||||
"signOutOtherDevices": "Encerrar sessão em outros dispositivos",
|
||||
"doNotSignOut": "Não encerrar sessão",
|
||||
"editLocation": "Editar local",
|
||||
"selectALocation": "Selecionar um local",
|
||||
|
@ -1204,5 +1204,12 @@
|
|||
"addViewers": "{count, plural, zero {Adicionar visualizador} one {Adicionar visualizador} other {Adicionar Visualizadores}}",
|
||||
"addCollaborators": "{count, plural, zero {Adicionar colaborador} one {Adicionar coloborador} other {Adicionar colaboradores}}",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "Pressione e segure um e-mail para verificar a criptografia de ponta a ponta.",
|
||||
"createCollaborativeLink": "Create collaborative link"
|
||||
"developerSettingsWarning": "Tem certeza de que deseja modificar as configurações de Desenvolvedor?",
|
||||
"developerSettings": "Configurações de desenvolvedor",
|
||||
"serverEndpoint": "Servidor endpoint",
|
||||
"invalidEndpoint": "Endpoint inválido",
|
||||
"invalidEndpointMessage": "Desculpe, o endpoint que você inseriu é inválido. Por favor, insira um endpoint válido e tente novamente.",
|
||||
"endpointUpdatedMessage": "Endpoint atualizado com sucesso",
|
||||
"customEndpoint": "Conectado a {endpoint}",
|
||||
"createCollaborativeLink": "Criar link colaborativo"
|
||||
}
|
|
@ -23,7 +23,7 @@
|
|||
"sendEmail": "发送电子邮件",
|
||||
"deleteRequestSLAText": "您的请求将在 72 小时内处理。",
|
||||
"deleteEmailRequest": "请从您注册的电子邮件地址发送电子邮件到 <warning>account-delettion@ente.io</warning>。",
|
||||
"entePhotosPerm": "ente <i>需要许可</i>才能保存您的照片",
|
||||
"entePhotosPerm": "Ente <i>需要许可</i>才能保存您的照片",
|
||||
"ok": "OK",
|
||||
"createAccount": "创建账户",
|
||||
"createNewAccount": "创建新账号",
|
||||
|
@ -127,7 +127,7 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"twofactorSetup": "双因素认证设置",
|
||||
"twofactorSetup": "双重认证设置",
|
||||
"enterCode": "输入代码",
|
||||
"scanCode": "扫描二维码/条码",
|
||||
"codeCopiedToClipboard": "代码已复制到剪贴板",
|
||||
|
@ -138,9 +138,9 @@
|
|||
"confirm": "确认",
|
||||
"setupComplete": "设置完成",
|
||||
"saveYourRecoveryKeyIfYouHaventAlready": "若您尚未保存,请妥善保存此恢复密钥",
|
||||
"thisCanBeUsedToRecoverYourAccountIfYou": "如果您丢失了双因素验证方式,这可以用来恢复您的账户",
|
||||
"twofactorAuthenticationPageTitle": "双因素认证",
|
||||
"lostDevice": "丢失了设备吗?",
|
||||
"thisCanBeUsedToRecoverYourAccountIfYou": "如果您丢失了双重认证方式,这可以用来恢复您的账户",
|
||||
"twofactorAuthenticationPageTitle": "双重认证",
|
||||
"lostDevice": "设备丢失?",
|
||||
"verifyingRecoveryKey": "正在验证恢复密钥...",
|
||||
"recoveryKeyVerified": "恢复密钥已验证",
|
||||
"recoveryKeySuccessBody": "太棒了! 您的恢复密钥是有效的。 感谢您的验证。\n\n请记住要安全备份您的恢复密钥。",
|
||||
|
@ -225,17 +225,17 @@
|
|||
},
|
||||
"description": "Number of participants in an album, including the album owner."
|
||||
},
|
||||
"collabLinkSectionDescription": "创建一个链接以允许其他人在您的共享相册中添加和查看照片,而无需应用程序或ente账户。 非常适合收集活动照片。",
|
||||
"collabLinkSectionDescription": "创建一个链接来让他人无需 Ente 应用程序或账户即可在您的共享相册中添加和查看照片。非常适合收集活动照片。",
|
||||
"collectPhotos": "收集照片",
|
||||
"collaborativeLink": "协作链接",
|
||||
"shareWithNonenteUsers": "与非ente 用户分享",
|
||||
"shareWithNonenteUsers": "与非 Ente 用户共享",
|
||||
"createPublicLink": "创建公开链接",
|
||||
"sendLink": "发送链接",
|
||||
"copyLink": "复制链接",
|
||||
"linkHasExpired": "链接已过期",
|
||||
"publicLinkEnabled": "公开链接已启用",
|
||||
"shareALink": "分享链接",
|
||||
"sharedAlbumSectionDescription": "与其他ente用户创建共享和协作相册,包括免费计划的用户。",
|
||||
"sharedAlbumSectionDescription": "与其他 Ente 用户(包括免费计划用户)创建共享和协作相册。",
|
||||
"shareWithPeopleSectionTitle": "{numberOfPeople, plural, =0 {与特定人员共享} =1 {与 1 人共享} other {与 {numberOfPeople} 人共享}}",
|
||||
"@shareWithPeopleSectionTitle": {
|
||||
"placeholders": {
|
||||
|
@ -259,12 +259,12 @@
|
|||
},
|
||||
"verificationId": "验证 ID",
|
||||
"verifyEmailID": "验证 {email}",
|
||||
"emailNoEnteAccount": "{email} 没有 ente 账户。\n\n向他们发送分享照片的邀请。",
|
||||
"emailNoEnteAccount": "{email} 没有 Ente 帐户。\n\n向他们发出共享照片的邀请。",
|
||||
"shareMyVerificationID": "这是我的ente.io 的验证 ID: {verificationID}。",
|
||||
"shareTextConfirmOthersVerificationID": "嘿,你能确认这是你的 ente.io 验证 ID吗:{verificationID}",
|
||||
"somethingWentWrong": "出了些问题",
|
||||
"sendInvite": "发送邀请",
|
||||
"shareTextRecommendUsingEnte": "下载 ente,以便我们轻松分享原始质量的照片和视频\n\nhttps://ente.io",
|
||||
"shareTextRecommendUsingEnte": "下载 Ente,让我们轻松共享高质量的原始照片和视频",
|
||||
"done": "已完成",
|
||||
"applyCodeTitle": "应用代码",
|
||||
"enterCodeDescription": "输入您的朋友提供的代码来为您申请免费存储",
|
||||
|
@ -281,7 +281,7 @@
|
|||
"claimMore": "领取更多!",
|
||||
"theyAlsoGetXGb": "他们也会获得 {storageAmountInGB} GB",
|
||||
"freeStorageOnReferralSuccess": "每当有人使用您的代码注册付费计划时您将获得{storageAmountInGB} GB",
|
||||
"shareTextReferralCode": "ente推荐码: {referralCode} \n\n注册付费计划后在设置 → 常规 → 推荐中应用它以免费获得 {referralStorageInGB} GB空间\n\nhttps://ente.io",
|
||||
"shareTextReferralCode": "Ente 推荐代码:{referralCode}\n\n在 \"设置\"→\"通用\"→\"推荐 \"中应用它,即可在注册付费计划后免费获得 {referralStorageInGB} GB 存储空间\n\nhttps://ente.io",
|
||||
"claimFreeStorage": "领取免费存储",
|
||||
"inviteYourFriends": "邀请您的朋友",
|
||||
"failedToFetchReferralDetails": "无法获取引荐详细信息。 请稍后再试。",
|
||||
|
@ -334,9 +334,9 @@
|
|||
"removeParticipantBody": "{userEmail} 将从这个共享相册中删除\n\nTA们添加的任何照片也将从相册中删除",
|
||||
"keepPhotos": "保留照片",
|
||||
"deletePhotos": "删除照片",
|
||||
"inviteToEnte": "邀请到 ente",
|
||||
"inviteToEnte": "邀请到 Ente",
|
||||
"removePublicLink": "删除公开链接",
|
||||
"disableLinkMessage": "这将删除用于访问\"{albumName}\"的公共链接。",
|
||||
"disableLinkMessage": "这将删除用于访问\"{albumName}\"的公开链接。",
|
||||
"sharing": "正在分享...",
|
||||
"youCannotShareWithYourself": "莫开玩笑,您不能与自己分享",
|
||||
"archive": "存档",
|
||||
|
@ -350,10 +350,10 @@
|
|||
"videoSmallCase": "视频",
|
||||
"photoSmallCase": "照片",
|
||||
"singleFileDeleteHighlight": "它将从所有相册中删除。",
|
||||
"singleFileInBothLocalAndRemote": "此 {fileType} 同时在ente和您的设备中。",
|
||||
"singleFileInRemoteOnly": "此 {fileType} 将从ente中删除。",
|
||||
"singleFileInBothLocalAndRemote": "{fileType} 已同时存在于 Ente 和您的设备中。",
|
||||
"singleFileInRemoteOnly": "{fileType} 将从 Ente 中删除。",
|
||||
"singleFileDeleteFromDevice": "此 {fileType} 将从您的设备中删除。",
|
||||
"deleteFromEnte": "从ente 中删除",
|
||||
"deleteFromEnte": "从 Ente 中删除",
|
||||
"yesDelete": "是的, 删除",
|
||||
"movedToTrash": "已移至回收站",
|
||||
"deleteFromDevice": "从设备中删除",
|
||||
|
@ -445,7 +445,7 @@
|
|||
"backupOverMobileData": "通过移动数据备份",
|
||||
"backupVideos": "备份视频",
|
||||
"disableAutoLock": "禁用自动锁定",
|
||||
"deviceLockExplanation": "当 ente 在前台并且正在进行备份时禁用设备屏幕锁定。 这通常不需要,但可以帮助大型库的大上传和初始导入更快地完成。",
|
||||
"deviceLockExplanation": "当 Ente 置于前台且正在进行备份时将禁用设备屏幕锁定。这通常是不需要的,但可能有助于更快地完成大型上传和大型库的初始导入。",
|
||||
"about": "关于",
|
||||
"weAreOpenSource": "我们是开源的 !",
|
||||
"privacy": "隐私",
|
||||
|
@ -465,7 +465,7 @@
|
|||
"authToInitiateAccountDeletion": "请进行身份验证以启动账户删除",
|
||||
"areYouSureYouWantToLogout": "您确定要退出登录吗?",
|
||||
"yesLogout": "是的,退出登陆",
|
||||
"aNewVersionOfEnteIsAvailable": "有新版本的 ente 可供使用。",
|
||||
"aNewVersionOfEnteIsAvailable": "有新版本的 Ente 可供使用。",
|
||||
"update": "更新",
|
||||
"installManually": "手动安装",
|
||||
"criticalUpdateAvailable": "可用的关键更新",
|
||||
|
@ -515,7 +515,7 @@
|
|||
}
|
||||
},
|
||||
"familyPlans": "家庭计划",
|
||||
"referrals": "推荐人",
|
||||
"referrals": "推荐",
|
||||
"notifications": "通知",
|
||||
"sharedPhotoNotifications": "新共享的照片",
|
||||
"sharedPhotoNotificationsExplanation": "当有人将照片添加到您所属的共享相册时收到通知",
|
||||
|
@ -523,15 +523,15 @@
|
|||
"general": "通用",
|
||||
"security": "安全",
|
||||
"authToViewYourRecoveryKey": "请验证以查看您的恢复密钥",
|
||||
"twofactor": "两因素认证",
|
||||
"authToConfigureTwofactorAuthentication": "请进行身份验证以配置双重身份验证",
|
||||
"twofactor": "双重认证",
|
||||
"authToConfigureTwofactorAuthentication": "请进行身份验证以配置双重身份认证",
|
||||
"lockscreen": "锁屏",
|
||||
"authToChangeLockscreenSetting": "请验证以更改锁屏设置",
|
||||
"lockScreenEnablePreSteps": "要启用锁屏,请在系统设置中设置设备密码或屏幕锁定。",
|
||||
"viewActiveSessions": "查看活动会话",
|
||||
"authToViewYourActiveSessions": "请验证以查看您的活动会话",
|
||||
"disableTwofactor": "禁用双因素认证",
|
||||
"confirm2FADisable": "您确定要禁用双因素认证吗?",
|
||||
"disableTwofactor": "禁用双重认证",
|
||||
"confirm2FADisable": "您确定要禁用双重认证吗?",
|
||||
"no": "否",
|
||||
"yes": "是",
|
||||
"social": "社交",
|
||||
|
@ -554,11 +554,11 @@
|
|||
"systemTheme": "适应系统",
|
||||
"freeTrial": "免费试用",
|
||||
"selectYourPlan": "选择您的计划",
|
||||
"enteSubscriptionPitch": "ente 会保留您的回忆,因此即使您丢失了设备,它们也始终可供您使用。",
|
||||
"enteSubscriptionPitch": "Ente 会保留您的回忆,因此即使您丢失了设备,也能随时找到它们。",
|
||||
"enteSubscriptionShareWithFamily": "您的家人也可以添加到您的计划中。",
|
||||
"currentUsageIs": "当前用量 ",
|
||||
"@currentUsageIs": {
|
||||
"description": "This text is followed by storage usaged",
|
||||
"description": "This text is followed by storage usage",
|
||||
"examples": {
|
||||
"0": "Current usage is 1.2 GB"
|
||||
},
|
||||
|
@ -620,7 +620,7 @@
|
|||
"appleId": "Apple ID",
|
||||
"playstoreSubscription": "PlayStore 订阅",
|
||||
"appstoreSubscription": "AppStore 订阅",
|
||||
"subAlreadyLinkedErrMessage": "您的 {id} 已经链接到另一个ente账户。\n如果您想要通过此账户使用您的 {id} ,请联系我们的客服''",
|
||||
"subAlreadyLinkedErrMessage": "您的 {id} 已链接到另一个 Ente 账户。\n如果您想在此账户中使用您的 {id} ,请联系我们的支持人员",
|
||||
"visitWebToManage": "请访问 web.ente.io 来管理您的订阅",
|
||||
"couldNotUpdateSubscription": "无法升级订阅",
|
||||
"pleaseContactSupportAndWeWillBeHappyToHelp": "请用英语联系 support@ente.io ,我们将乐意提供帮助!",
|
||||
|
@ -665,9 +665,9 @@
|
|||
"everywhere": "随时随地",
|
||||
"androidIosWebDesktop": "安卓, iOS, 网页端, 桌面端",
|
||||
"mobileWebDesktop": "移动端, 网页端, 桌面端",
|
||||
"newToEnte": "刚来到ente",
|
||||
"newToEnte": "初来 Ente",
|
||||
"pleaseLoginAgain": "请重新登录",
|
||||
"devAccountChanged": "我们用于在 App Store 上发布 ente 的开发者账户已更改。 因此,您将需要重新登录。\n\n对于给您带来的不便,我们深表歉意,但这是不可避免的。",
|
||||
"devAccountChanged": "我们用于在 App Store 上发布 Ente 的开发者账户已更改。因此,您需要重新登录。\n\n对于给您带来的不便,我们深表歉意,但这是不可避免的。",
|
||||
"yourSubscriptionHasExpired": "您的订阅已过期",
|
||||
"storageLimitExceeded": "已超出存储限制",
|
||||
"upgrade": "升级",
|
||||
|
@ -678,12 +678,12 @@
|
|||
},
|
||||
"backupFailed": "备份失败",
|
||||
"couldNotBackUpTryLater": "我们无法备份您的数据。\n我们将稍后再试。",
|
||||
"enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "只有您授予访问权限,ente 才能加密和保存文件",
|
||||
"enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "仅当您授予文件访问权限时,Ente 才能加密和保存文件",
|
||||
"pleaseGrantPermissions": "请授予权限",
|
||||
"grantPermission": "授予权限",
|
||||
"privateSharing": "私人共享",
|
||||
"privateSharing": "私人分享",
|
||||
"shareOnlyWithThePeopleYouWant": "仅与您想要的人分享",
|
||||
"usePublicLinksForPeopleNotOnEnte": "为不在ente 上的人使用公共链接",
|
||||
"usePublicLinksForPeopleNotOnEnte": "对不在 Ente 上的人使用公开链接",
|
||||
"allowPeopleToAddPhotos": "允许人们添加照片",
|
||||
"shareAnAlbumNow": "立即分享相册",
|
||||
"collectEventPhotos": "收集活动照片",
|
||||
|
@ -695,7 +695,7 @@
|
|||
},
|
||||
"onDevice": "在设备上",
|
||||
"@onEnte": {
|
||||
"description": "The text displayed above albums backed up to ente",
|
||||
"description": "The text displayed above albums backed up to Ente",
|
||||
"type": "text"
|
||||
},
|
||||
"onEnte": "在 <branding>ente</branding> 上",
|
||||
|
@ -741,7 +741,7 @@
|
|||
"saveCollage": "保存拼贴",
|
||||
"collageSaved": "拼贴已保存到相册",
|
||||
"collageLayout": "布局",
|
||||
"addToEnte": "添加到 ente",
|
||||
"addToEnte": "添加到 Ente",
|
||||
"addToAlbum": "添加到相册",
|
||||
"delete": "删除",
|
||||
"hide": "隐藏",
|
||||
|
@ -806,10 +806,10 @@
|
|||
"photosAddedByYouWillBeRemovedFromTheAlbum": "您添加的照片将从相册中移除",
|
||||
"youveNoFilesInThisAlbumThatCanBeDeleted": "您在此相册中没有可以删除的文件",
|
||||
"youDontHaveAnyArchivedItems": "您没有任何存档的项目。",
|
||||
"ignoredFolderUploadReason": "此相册中的某些文件在上传时被忽略,因为它们之前已从 ente 中删除。",
|
||||
"ignoredFolderUploadReason": "此相册中的某些文件在上传时会被忽略,因为它们之前已从 Ente 中删除。",
|
||||
"resetIgnoredFiles": "重置忽略的文件",
|
||||
"deviceFilesAutoUploading": "添加到此设备相册的文件将自动上传到 ente。",
|
||||
"turnOnBackupForAutoUpload": "打开备份以自动上传添加到此设备文件夹的文件。",
|
||||
"deviceFilesAutoUploading": "添加到此设备相册的文件将自动上传到 Ente。",
|
||||
"turnOnBackupForAutoUpload": "打开备份可自动上传添加到此设备文件夹的文件至 Ente。",
|
||||
"noHiddenPhotosOrVideos": "没有隐藏的照片或视频",
|
||||
"toHideAPhotoOrVideo": "隐藏照片或视频",
|
||||
"openTheItem": "• 打开该项目",
|
||||
|
@ -886,7 +886,7 @@
|
|||
"@freeUpSpaceSaving": {
|
||||
"description": "Text to tell user how much space they can free up by deleting items from the device"
|
||||
},
|
||||
"freeUpAccessPostDelete": "只要您有有效的订阅,您仍然可以在 ente 上访问 {count, plural, one {it} other {them}}",
|
||||
"freeUpAccessPostDelete": "只要您有有效的订阅,您仍然可以在 Ente 上访问 {count, plural, one {它} other {它们}}",
|
||||
"@freeUpAccessPostDelete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
|
@ -904,15 +904,15 @@
|
|||
"authenticationSuccessful": "验证成功",
|
||||
"incorrectRecoveryKey": "不正确的恢复密钥",
|
||||
"theRecoveryKeyYouEnteredIsIncorrect": "您输入的恢复密钥不正确",
|
||||
"twofactorAuthenticationSuccessfullyReset": "成功重置双因素认证",
|
||||
"twofactorAuthenticationSuccessfullyReset": "成功重置双重认证",
|
||||
"pleaseVerifyTheCodeYouHaveEntered": "请验证您输入的代码",
|
||||
"pleaseContactSupportIfTheProblemPersists": "如果问题仍然存在,请联系支持",
|
||||
"twofactorAuthenticationHasBeenDisabled": "双因素认证已被禁用",
|
||||
"twofactorAuthenticationHasBeenDisabled": "双重认证已被禁用",
|
||||
"sorryTheCodeYouveEnteredIsIncorrect": "抱歉,您输入的代码不正确",
|
||||
"yourVerificationCodeHasExpired": "您的验证码已过期",
|
||||
"emailChangedTo": "电子邮件已更改为 {newEmail}",
|
||||
"verifying": "正在验证...",
|
||||
"disablingTwofactorAuthentication": "正在禁用双因素认证...",
|
||||
"disablingTwofactorAuthentication": "正在禁用双重认证...",
|
||||
"allMemoriesPreserved": "所有回忆都已保存",
|
||||
"loadingGallery": "正在加载图库...",
|
||||
"syncing": "正在同步···",
|
||||
|
@ -937,7 +937,7 @@
|
|||
"renameFile": "重命名文件",
|
||||
"enterFileName": "请输入文件名",
|
||||
"filesDeleted": "文件已删除",
|
||||
"selectedFilesAreNotOnEnte": "所选文件不在ente上",
|
||||
"selectedFilesAreNotOnEnte": "所选文件不在 Ente 上",
|
||||
"thisActionCannotBeUndone": "此操作无法撤销",
|
||||
"emptyTrash": "要清空回收站吗?",
|
||||
"permDeleteWarning": "回收站中的所有项目将被永久删除\n\n此操作无法撤消",
|
||||
|
@ -946,7 +946,7 @@
|
|||
"permanentlyDeleteFromDevice": "要从设备中永久删除吗?",
|
||||
"someOfTheFilesYouAreTryingToDeleteAre": "您要删除的部分文件仅在您的设备上可用,且删除后无法恢复",
|
||||
"theyWillBeDeletedFromAllAlbums": "他们将从所有相册中删除。",
|
||||
"someItemsAreInBothEnteAndYourDevice": "有些项目既在ente 也在您的设备中。",
|
||||
"someItemsAreInBothEnteAndYourDevice": "有些项目同时存在于 Ente 和您的设备中。",
|
||||
"selectedItemsWillBeDeletedFromAllAlbumsAndMoved": "所选项目将从所有相册中删除并移动到回收站。",
|
||||
"theseItemsWillBeDeletedFromYourDevice": "这些项目将从您的设备中删除。",
|
||||
"itLooksLikeSomethingWentWrongPleaseRetryAfterSome": "看起来出了点问题。 请稍后重试。 如果错误仍然存在,请联系我们的支持团队。",
|
||||
|
@ -1052,7 +1052,7 @@
|
|||
},
|
||||
"setRadius": "设定半径",
|
||||
"familyPlanPortalTitle": "家庭",
|
||||
"familyPlanOverview": "在您现有的计划中添加 5 名家庭成员而无需支付额外费用。\n\n每个成员都有自己的私人空间,除非共享,否则无法看到彼此的文件。\n\n家庭计划适用于已有付费订阅的客户。\n\n立即订阅以开始使用!",
|
||||
"familyPlanOverview": "将 5 名家庭成员添加到您现有的计划中,无需支付额外费用。\n\n每个成员都有自己的私人空间,除非共享,否则无法看到彼此的文件。\n\n家庭计划适用于已付费 Ente 订阅的客户。\n\n立即订阅,开始体验!",
|
||||
"androidBiometricHint": "验证身份",
|
||||
"@androidBiometricHint": {
|
||||
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."
|
||||
|
@ -1130,7 +1130,7 @@
|
|||
"noAlbumsSharedByYouYet": "您尚未共享任何相册",
|
||||
"sharedWithYou": "已与您共享",
|
||||
"sharedByYou": "您共享的",
|
||||
"inviteYourFriendsToEnte": "邀请您的好友加入ente",
|
||||
"inviteYourFriendsToEnte": "邀请您的朋友加入 Ente",
|
||||
"failedToDownloadVideo": "视频下载失败",
|
||||
"hiding": "正在隐藏...",
|
||||
"unhiding": "正在取消隐藏...",
|
||||
|
@ -1140,7 +1140,7 @@
|
|||
"addToHiddenAlbum": "添加到隐藏相册",
|
||||
"moveToHiddenAlbum": "移至隐藏相册",
|
||||
"fileTypes": "文件类型",
|
||||
"deleteConfirmDialogBody": "此账户已链接到其他 ente 旗下的应用程序(如果您使用任何 ente 旗下的应用程序)。\\n\\n您在所有 ente 旗下的应用程序中上传的数据将被安排删除,并且您的账户将被永久删除。",
|
||||
"deleteConfirmDialogBody": "此账户已链接到其他 Ente 应用程序(如果您使用任何应用程序)。您在所有 Ente 应用程序中上传的数据将被安排删除,并且您的账户将被永久删除。",
|
||||
"hearUsWhereTitle": "您是如何知道Ente的? (可选的)",
|
||||
"hearUsExplanation": "我们不跟踪应用程序安装情况。如果您告诉我们您是在哪里找到我们的,将会有所帮助!",
|
||||
"viewAddOnButton": "查看附加组件",
|
||||
|
@ -1204,5 +1204,12 @@
|
|||
"addViewers": "{count, plural, zero {添加查看者} one {添加查看者} other {添加查看者}}",
|
||||
"addCollaborators": "{count, plural, zero {添加协作者} one {添加协作者} other {添加协作者}}",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "长按电子邮件以验证端到端加密。",
|
||||
"createCollaborativeLink": "Create collaborative link"
|
||||
"developerSettingsWarning": "您确定要修改开发者设置吗?",
|
||||
"developerSettings": "开发者设置",
|
||||
"serverEndpoint": "服务器端点",
|
||||
"invalidEndpoint": "端点无效",
|
||||
"invalidEndpointMessage": "抱歉,您输入的端点无效。请输入有效的端点,然后重试。",
|
||||
"endpointUpdatedMessage": "端点更新成功",
|
||||
"customEndpoint": "已连接至 {endpoint}",
|
||||
"createCollaborativeLink": "创建协作链接"
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import { CustomHead } from "@/next/components/Head";
|
||||
import { setupI18n } from "@/next/i18n";
|
||||
import { logUnhandledErrorsAndRejections } from "@/next/log-web";
|
||||
import { APPS, APP_TITLES } from "@ente/shared/apps/constants";
|
||||
import { Overlay } from "@ente/shared/components/Container";
|
||||
import DialogBoxV2 from "@ente/shared/components/DialogBoxV2";
|
||||
|
@ -54,6 +55,8 @@ export default function App({ Component, pageProps }: AppProps) {
|
|||
|
||||
useEffect(() => {
|
||||
setupI18n().finally(() => setIsI18nReady(true));
|
||||
logUnhandledErrorsAndRejections(true);
|
||||
return () => logUnhandledErrorsAndRejections(false);
|
||||
}, []);
|
||||
|
||||
const setupPackageName = () => {
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import { CustomHead } from "@/next/components/Head";
|
||||
import { setupI18n } from "@/next/i18n";
|
||||
import { logStartupBanner } from "@/next/log-web";
|
||||
import {
|
||||
logStartupBanner,
|
||||
logUnhandledErrorsAndRejections,
|
||||
} from "@/next/log-web";
|
||||
import {
|
||||
APPS,
|
||||
APP_TITLES,
|
||||
|
@ -68,9 +71,11 @@ export default function App({ Component, pageProps }: AppProps) {
|
|||
setupI18n().finally(() => setIsI18nReady(true));
|
||||
const userId = (getData(LS_KEYS.USER) as User)?.id;
|
||||
logStartupBanner(APPS.AUTH, userId);
|
||||
logUnhandledErrorsAndRejections(true);
|
||||
HTTPService.setHeaders({
|
||||
"X-Client-Package": CLIENT_PACKAGE_NAMES.get(APPS.AUTH),
|
||||
});
|
||||
return () => logUnhandledErrorsAndRejections(false);
|
||||
}, []);
|
||||
|
||||
const setUserOnline = () => setOffline(false);
|
||||
|
|
|
@ -1,12 +1,20 @@
|
|||
import { CustomHead } from "@/next/components/Head";
|
||||
import { logUnhandledErrorsAndRejections } from "@/next/log-web";
|
||||
import { APPS, APP_TITLES } from "@ente/shared/apps/constants";
|
||||
import { getTheme } from "@ente/shared/themes";
|
||||
import { THEME_COLOR } from "@ente/shared/themes/constants";
|
||||
import { CssBaseline, ThemeProvider } from "@mui/material";
|
||||
import type { AppProps } from "next/app";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import "styles/global.css";
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
useEffect(() => {
|
||||
logUnhandledErrorsAndRejections(true);
|
||||
return () => logUnhandledErrorsAndRejections(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomHead title={APP_TITLES.get(APPS.PHOTOS)} />
|
||||
|
|
|
@ -14,7 +14,7 @@ import { EnteMenuItem } from "components/Menu/EnteMenuItem";
|
|||
import { MenuItemGroup } from "components/Menu/MenuItemGroup";
|
||||
import isElectron from "is-electron";
|
||||
import { AppContext } from "pages/_app";
|
||||
import { ClipExtractionStatus, ClipService } from "services/clipService";
|
||||
import { CLIPIndexingStatus, clipService } from "services/clip-service";
|
||||
import { formatNumber } from "utils/number/format";
|
||||
|
||||
export default function AdvancedSettings({ open, onClose, onRootClose }) {
|
||||
|
@ -44,17 +44,15 @@ export default function AdvancedSettings({ open, onClose, onRootClose }) {
|
|||
log.error("toggleFasterUpload failed", e);
|
||||
}
|
||||
};
|
||||
const [indexingStatus, setIndexingStatus] = useState<ClipExtractionStatus>({
|
||||
const [indexingStatus, setIndexingStatus] = useState<CLIPIndexingStatus>({
|
||||
indexed: 0,
|
||||
pending: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const main = async () => {
|
||||
setIndexingStatus(await ClipService.getIndexingStatus());
|
||||
ClipService.setOnUpdateHandler(setIndexingStatus);
|
||||
};
|
||||
main();
|
||||
clipService.setOnUpdateHandler(setIndexingStatus);
|
||||
clipService.getIndexingStatus().then((st) => setIndexingStatus(st));
|
||||
return () => clipService.setOnUpdateHandler(undefined);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import { CustomHead } from "@/next/components/Head";
|
||||
import { setupI18n } from "@/next/i18n";
|
||||
import log from "@/next/log";
|
||||
import { logStartupBanner } from "@/next/log-web";
|
||||
import {
|
||||
logStartupBanner,
|
||||
logUnhandledErrorsAndRejections,
|
||||
} from "@/next/log-web";
|
||||
import { AppUpdateInfo } from "@/next/types/ipc";
|
||||
import {
|
||||
APPS,
|
||||
|
@ -147,35 +150,35 @@ export default function App({ Component, pageProps }: AppProps) {
|
|||
setupI18n().finally(() => setIsI18nReady(true));
|
||||
const userId = (getData(LS_KEYS.USER) as User)?.id;
|
||||
logStartupBanner(APPS.PHOTOS, userId);
|
||||
logUnhandledErrorsAndRejections(true);
|
||||
HTTPService.setHeaders({
|
||||
"X-Client-Package": CLIENT_PACKAGE_NAMES.get(APPS.PHOTOS),
|
||||
});
|
||||
return () => logUnhandledErrorsAndRejections(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const electron = globalThis.electron;
|
||||
if (electron) {
|
||||
const showUpdateDialog = (updateInfo: AppUpdateInfo) => {
|
||||
if (updateInfo.autoUpdatable) {
|
||||
setDialogMessage(
|
||||
getUpdateReadyToInstallMessage(updateInfo),
|
||||
);
|
||||
} else {
|
||||
setNotificationAttributes({
|
||||
endIcon: <ArrowForward />,
|
||||
variant: "secondary",
|
||||
message: t("UPDATE_AVAILABLE"),
|
||||
onClick: () =>
|
||||
setDialogMessage(
|
||||
getUpdateAvailableForDownloadMessage(
|
||||
updateInfo,
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
electron.registerUpdateEventListener(showUpdateDialog);
|
||||
}
|
||||
if (!electron) return;
|
||||
|
||||
const showUpdateDialog = (updateInfo: AppUpdateInfo) => {
|
||||
if (updateInfo.autoUpdatable) {
|
||||
setDialogMessage(getUpdateReadyToInstallMessage(updateInfo));
|
||||
} else {
|
||||
setNotificationAttributes({
|
||||
endIcon: <ArrowForward />,
|
||||
variant: "secondary",
|
||||
message: t("UPDATE_AVAILABLE"),
|
||||
onClick: () =>
|
||||
setDialogMessage(
|
||||
getUpdateAvailableForDownloadMessage(updateInfo),
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
electron.onAppUpdateAvailable(showUpdateDialog);
|
||||
|
||||
return () => electron.onAppUpdateAvailable(undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -102,7 +102,7 @@ import {
|
|||
} from "constants/collection";
|
||||
import { SYNC_INTERVAL_IN_MICROSECONDS } from "constants/gallery";
|
||||
import { AppContext } from "pages/_app";
|
||||
import { ClipService } from "services/clipService";
|
||||
import { clipService } from "services/clip-service";
|
||||
import { constructUserIDToEmailMap } from "services/collectionService";
|
||||
import downloadManager from "services/download";
|
||||
import { syncEmbeddings } from "services/embeddingService";
|
||||
|
@ -362,18 +362,16 @@ export default function Gallery() {
|
|||
syncWithRemote(false, true);
|
||||
}, SYNC_INTERVAL_IN_MICROSECONDS);
|
||||
if (electron) {
|
||||
void ClipService.setupOnFileUploadListener();
|
||||
electron.registerForegroundEventListener(() => {
|
||||
syncWithRemote(false, true);
|
||||
});
|
||||
void clipService.setupOnFileUploadListener();
|
||||
electron.onMainWindowFocus(() => syncWithRemote(false, true));
|
||||
}
|
||||
};
|
||||
main();
|
||||
return () => {
|
||||
clearInterval(syncInterval.current);
|
||||
if (electron) {
|
||||
electron.registerForegroundEventListener(() => {});
|
||||
ClipService.removeOnFileUploadListener();
|
||||
electron.onMainWindowFocus(undefined);
|
||||
clipService.removeOnFileUploadListener();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
@ -704,8 +702,8 @@ export default function Gallery() {
|
|||
await syncEntities();
|
||||
await syncMapEnabled();
|
||||
await syncEmbeddings();
|
||||
if (ClipService.isPlatformSupported()) {
|
||||
void ClipService.scheduleImageEmbeddingExtraction();
|
||||
if (clipService.isPlatformSupported()) {
|
||||
void clipService.scheduleImageEmbeddingExtraction();
|
||||
}
|
||||
} catch (e) {
|
||||
switch (e.message) {
|
||||
|
|
|
@ -133,9 +133,9 @@ export default function LandingPage() {
|
|||
const electron = globalThis.electron;
|
||||
if (!key && electron) {
|
||||
try {
|
||||
key = await electron.getEncryptionKey();
|
||||
key = await electron.encryptionKey();
|
||||
} catch (e) {
|
||||
log.error("getEncryptionKey failed", e);
|
||||
log.error("Failed to get encryption key from electron", e);
|
||||
}
|
||||
if (key) {
|
||||
await saveKeyInSessionStore(
|
||||
|
|
|
@ -7,29 +7,70 @@ import { LS_KEYS, getData } from "@ente/shared/storage/localStorage";
|
|||
import { FILE_TYPE } from "constants/file";
|
||||
import isElectron from "is-electron";
|
||||
import PQueue from "p-queue";
|
||||
import { Embedding, Model } from "types/embedding";
|
||||
import { Embedding } from "types/embedding";
|
||||
import { EnteFile } from "types/file";
|
||||
import { getPersonalFiles } from "utils/file";
|
||||
import downloadManager from "./download";
|
||||
import { getLocalEmbeddings, putEmbedding } from "./embeddingService";
|
||||
import { getAllLocalFiles, getLocalFiles } from "./fileService";
|
||||
|
||||
const CLIP_EMBEDDING_LENGTH = 512;
|
||||
|
||||
export interface ClipExtractionStatus {
|
||||
/** Status of CLIP indexing on the images in the user's local library. */
|
||||
export interface CLIPIndexingStatus {
|
||||
/** Number of items pending indexing. */
|
||||
pending: number;
|
||||
/** Number of items that have already been indexed. */
|
||||
indexed: number;
|
||||
}
|
||||
|
||||
class ClipServiceImpl {
|
||||
/**
|
||||
* Use a CLIP based neural network for natural language search.
|
||||
*
|
||||
* [Note: CLIP based magic search]
|
||||
*
|
||||
* CLIP (Contrastive Language-Image Pretraining) is a neural network trained on
|
||||
* (image, text) pairs. It can be thought of as two separate (but jointly
|
||||
* trained) encoders - one for images, and one for text - that both map to the
|
||||
* same embedding space.
|
||||
*
|
||||
* We use this for natural language search within the app (aka "magic search"):
|
||||
*
|
||||
* 1. Pre-compute an embedding for each image.
|
||||
*
|
||||
* 2. When the user searches, compute an embedding for the search term.
|
||||
*
|
||||
* 3. Use cosine similarity to find the find the image (embedding) closest to
|
||||
* the text (embedding).
|
||||
*
|
||||
* More details are in our [blog
|
||||
* post](https://ente.io/blog/image-search-with-clip-ggml/) that describes the
|
||||
* initial launch of this feature using the GGML runtime.
|
||||
*
|
||||
* Since the initial launch, we've switched over to another runtime,
|
||||
* [ONNX](https://onnxruntime.ai).
|
||||
*
|
||||
* Note that we don't train the neural network - we only use one of the publicly
|
||||
* available pre-trained neural networks for inference. These neural networks
|
||||
* are wholly defined by their connectivity and weights. ONNX, our ML runtimes,
|
||||
* loads these weights and instantiates a running network that we can use to
|
||||
* compute the embeddings.
|
||||
*
|
||||
* Theoretically, the same CLIP model can be loaded by different frameworks /
|
||||
* runtimes, but in practice each runtime has its own preferred format, and
|
||||
* there are also quantization tradeoffs. So there is a specific model (a binary
|
||||
* encoding of weights) tied to our current runtime that we use.
|
||||
*
|
||||
* To ensure that the embeddings, for the most part, can be shared, whenever
|
||||
* possible we try to ensure that all the preprocessing steps, and the model
|
||||
* itself, is the same across clients - web and mobile.
|
||||
*/
|
||||
class CLIPService {
|
||||
private embeddingExtractionInProgress: AbortController | null = null;
|
||||
private reRunNeeded = false;
|
||||
private clipExtractionStatus: ClipExtractionStatus = {
|
||||
private indexingStatus: CLIPIndexingStatus = {
|
||||
pending: 0,
|
||||
indexed: 0,
|
||||
};
|
||||
private onUpdateHandler: ((status: ClipExtractionStatus) => void) | null =
|
||||
null;
|
||||
private onUpdateHandler: ((status: CLIPIndexingStatus) => void) | undefined;
|
||||
private liveEmbeddingExtractionQueue: PQueue;
|
||||
private onFileUploadedHandler:
|
||||
| ((arg: { enteFile: EnteFile; localFile: globalThis.File }) => void)
|
||||
|
@ -96,28 +137,23 @@ class ClipServiceImpl {
|
|||
};
|
||||
|
||||
getIndexingStatus = async () => {
|
||||
try {
|
||||
if (
|
||||
!this.clipExtractionStatus ||
|
||||
(this.clipExtractionStatus.pending === 0 &&
|
||||
this.clipExtractionStatus.indexed === 0)
|
||||
) {
|
||||
this.clipExtractionStatus = await getClipExtractionStatus();
|
||||
}
|
||||
return this.clipExtractionStatus;
|
||||
} catch (e) {
|
||||
log.error("failed to get clip indexing status", e);
|
||||
if (
|
||||
this.indexingStatus.pending === 0 &&
|
||||
this.indexingStatus.indexed === 0
|
||||
) {
|
||||
this.indexingStatus = await initialIndexingStatus();
|
||||
}
|
||||
return this.indexingStatus;
|
||||
};
|
||||
|
||||
setOnUpdateHandler = (handler: (status: ClipExtractionStatus) => void) => {
|
||||
/**
|
||||
* Set the {@link handler} to invoke whenever our indexing status changes.
|
||||
*/
|
||||
setOnUpdateHandler = (handler?: (status: CLIPIndexingStatus) => void) => {
|
||||
this.onUpdateHandler = handler;
|
||||
handler(this.clipExtractionStatus);
|
||||
};
|
||||
|
||||
scheduleImageEmbeddingExtraction = async (
|
||||
model: Model = Model.ONNX_CLIP,
|
||||
) => {
|
||||
scheduleImageEmbeddingExtraction = async () => {
|
||||
try {
|
||||
if (this.embeddingExtractionInProgress) {
|
||||
log.info(
|
||||
|
@ -133,7 +169,7 @@ class ClipServiceImpl {
|
|||
const canceller = new AbortController();
|
||||
this.embeddingExtractionInProgress = canceller;
|
||||
try {
|
||||
await this.runClipEmbeddingExtraction(canceller, model);
|
||||
await this.runClipEmbeddingExtraction(canceller);
|
||||
} finally {
|
||||
this.embeddingExtractionInProgress = null;
|
||||
if (!canceller.signal.aborted && this.reRunNeeded) {
|
||||
|
@ -152,25 +188,19 @@ class ClipServiceImpl {
|
|||
}
|
||||
};
|
||||
|
||||
getTextEmbedding = async (
|
||||
text: string,
|
||||
model: Model = Model.ONNX_CLIP,
|
||||
): Promise<Float32Array> => {
|
||||
getTextEmbedding = async (text: string): Promise<Float32Array> => {
|
||||
try {
|
||||
return ensureElectron().computeTextEmbedding(model, text);
|
||||
return ensureElectron().clipTextEmbedding(text);
|
||||
} catch (e) {
|
||||
if (e?.message?.includes(CustomError.UNSUPPORTED_PLATFORM)) {
|
||||
this.unsupportedPlatform = true;
|
||||
}
|
||||
log.error("failed to compute text embedding", e);
|
||||
log.error("Failed to compute CLIP text embedding", e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
private runClipEmbeddingExtraction = async (
|
||||
canceller: AbortController,
|
||||
model: Model,
|
||||
) => {
|
||||
private runClipEmbeddingExtraction = async (canceller: AbortController) => {
|
||||
try {
|
||||
if (this.unsupportedPlatform) {
|
||||
log.info(
|
||||
|
@ -183,12 +213,12 @@ class ClipServiceImpl {
|
|||
return;
|
||||
}
|
||||
const localFiles = getPersonalFiles(await getAllLocalFiles(), user);
|
||||
const existingEmbeddings = await getLocalEmbeddings(model);
|
||||
const existingEmbeddings = await getLocalEmbeddings();
|
||||
const pendingFiles = await getNonClipEmbeddingExtractedFiles(
|
||||
localFiles,
|
||||
existingEmbeddings,
|
||||
);
|
||||
this.updateClipEmbeddingExtractionStatus({
|
||||
this.updateIndexingStatus({
|
||||
indexed: existingEmbeddings.length,
|
||||
pending: pendingFiles.length,
|
||||
});
|
||||
|
@ -208,15 +238,11 @@ class ClipServiceImpl {
|
|||
throw Error(CustomError.REQUEST_CANCELLED);
|
||||
}
|
||||
const embeddingData =
|
||||
await this.extractFileClipImageEmbedding(model, file);
|
||||
await this.extractFileClipImageEmbedding(file);
|
||||
log.info(
|
||||
`successfully extracted clip embedding for file: ${file.metadata.title} fileID: ${file.id} embedding length: ${embeddingData?.length}`,
|
||||
);
|
||||
await this.encryptAndUploadEmbedding(
|
||||
model,
|
||||
file,
|
||||
embeddingData,
|
||||
);
|
||||
await this.encryptAndUploadEmbedding(file, embeddingData);
|
||||
this.onSuccessStatusUpdater();
|
||||
log.info(
|
||||
`successfully put clip embedding to server for file: ${file.metadata.title} fileID: ${file.id}`,
|
||||
|
@ -249,13 +275,10 @@ class ClipServiceImpl {
|
|||
}
|
||||
};
|
||||
|
||||
private async runLocalFileClipExtraction(
|
||||
arg: {
|
||||
enteFile: EnteFile;
|
||||
localFile: globalThis.File;
|
||||
},
|
||||
model: Model = Model.ONNX_CLIP,
|
||||
) {
|
||||
private async runLocalFileClipExtraction(arg: {
|
||||
enteFile: EnteFile;
|
||||
localFile: globalThis.File;
|
||||
}) {
|
||||
const { enteFile, localFile } = arg;
|
||||
log.info(
|
||||
`clip embedding extraction onFileUploadedHandler file: ${enteFile.metadata.title} fileID: ${enteFile.id}`,
|
||||
|
@ -279,15 +302,9 @@ class ClipServiceImpl {
|
|||
);
|
||||
try {
|
||||
await this.liveEmbeddingExtractionQueue.add(async () => {
|
||||
const embedding = await this.extractLocalFileClipImageEmbedding(
|
||||
model,
|
||||
localFile,
|
||||
);
|
||||
await this.encryptAndUploadEmbedding(
|
||||
model,
|
||||
enteFile,
|
||||
embedding,
|
||||
);
|
||||
const embedding =
|
||||
await this.extractLocalFileClipImageEmbedding(localFile);
|
||||
await this.encryptAndUploadEmbedding(enteFile, embedding);
|
||||
});
|
||||
log.info(
|
||||
`successfully extracted clip embedding for file: ${enteFile.metadata.title} fileID: ${enteFile.id}`,
|
||||
|
@ -297,26 +314,18 @@ class ClipServiceImpl {
|
|||
}
|
||||
}
|
||||
|
||||
private extractLocalFileClipImageEmbedding = async (
|
||||
model: Model,
|
||||
localFile: File,
|
||||
) => {
|
||||
private extractLocalFileClipImageEmbedding = async (localFile: File) => {
|
||||
const file = await localFile
|
||||
.arrayBuffer()
|
||||
.then((buffer) => new Uint8Array(buffer));
|
||||
const embedding = await ensureElectron().computeImageEmbedding(
|
||||
model,
|
||||
file,
|
||||
);
|
||||
return embedding;
|
||||
return await ensureElectron().clipImageEmbedding(file);
|
||||
};
|
||||
|
||||
private encryptAndUploadEmbedding = async (
|
||||
model: Model,
|
||||
file: EnteFile,
|
||||
embeddingData: Float32Array,
|
||||
) => {
|
||||
if (embeddingData?.length !== CLIP_EMBEDDING_LENGTH) {
|
||||
if (embeddingData?.length !== 512) {
|
||||
throw Error(
|
||||
`invalid length embedding data length: ${embeddingData?.length}`,
|
||||
);
|
||||
|
@ -331,38 +340,31 @@ class ClipServiceImpl {
|
|||
fileID: file.id,
|
||||
encryptedEmbedding: encryptedEmbeddingData.encryptedData,
|
||||
decryptionHeader: encryptedEmbeddingData.decryptionHeader,
|
||||
model,
|
||||
model: "onnx-clip",
|
||||
});
|
||||
};
|
||||
|
||||
updateClipEmbeddingExtractionStatus = (status: ClipExtractionStatus) => {
|
||||
this.clipExtractionStatus = status;
|
||||
if (this.onUpdateHandler) {
|
||||
this.onUpdateHandler(status);
|
||||
}
|
||||
private updateIndexingStatus = (status: CLIPIndexingStatus) => {
|
||||
this.indexingStatus = status;
|
||||
const handler = this.onUpdateHandler;
|
||||
if (handler) handler(status);
|
||||
};
|
||||
|
||||
private extractFileClipImageEmbedding = async (
|
||||
model: Model,
|
||||
file: EnteFile,
|
||||
) => {
|
||||
private extractFileClipImageEmbedding = async (file: EnteFile) => {
|
||||
const thumb = await downloadManager.getThumbnail(file);
|
||||
const embedding = await ensureElectron().computeImageEmbedding(
|
||||
model,
|
||||
thumb,
|
||||
);
|
||||
const embedding = await ensureElectron().clipImageEmbedding(thumb);
|
||||
return embedding;
|
||||
};
|
||||
|
||||
private onSuccessStatusUpdater = () => {
|
||||
this.updateClipEmbeddingExtractionStatus({
|
||||
pending: this.clipExtractionStatus.pending - 1,
|
||||
indexed: this.clipExtractionStatus.indexed + 1,
|
||||
this.updateIndexingStatus({
|
||||
pending: this.indexingStatus.pending - 1,
|
||||
indexed: this.indexingStatus.indexed + 1,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const ClipService = new ClipServiceImpl();
|
||||
export const clipService = new CLIPService();
|
||||
|
||||
const getNonClipEmbeddingExtractedFiles = async (
|
||||
files: EnteFile[],
|
||||
|
@ -412,14 +414,10 @@ export const computeClipMatchScore = async (
|
|||
return score;
|
||||
};
|
||||
|
||||
const getClipExtractionStatus = async (
|
||||
model: Model = Model.ONNX_CLIP,
|
||||
): Promise<ClipExtractionStatus> => {
|
||||
const initialIndexingStatus = async (): Promise<CLIPIndexingStatus> => {
|
||||
const user = getData(LS_KEYS.USER);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
const allEmbeddings = await getLocalEmbeddings(model);
|
||||
if (!user) throw new Error("Orphan CLIP indexing without a login");
|
||||
const allEmbeddings = await getLocalEmbeddings();
|
||||
const localFiles = getPersonalFiles(await getLocalFiles(), user);
|
||||
const pendingFiles = await getNonClipEmbeddingExtractedFiles(
|
||||
localFiles,
|
|
@ -5,11 +5,11 @@ import HTTPService from "@ente/shared/network/HTTPService";
|
|||
import { getEndpoint } from "@ente/shared/network/api";
|
||||
import localForage from "@ente/shared/storage/localForage";
|
||||
import { getToken } from "@ente/shared/storage/localStorage/helpers";
|
||||
import {
|
||||
import type {
|
||||
Embedding,
|
||||
EmbeddingModel,
|
||||
EncryptedEmbedding,
|
||||
GetEmbeddingDiffResponse,
|
||||
Model,
|
||||
PutEmbeddingRequest,
|
||||
} from "types/embedding";
|
||||
import { EnteFile } from "types/file";
|
||||
|
@ -38,12 +38,12 @@ export const getAllLocalEmbeddings = async () => {
|
|||
return embeddings;
|
||||
};
|
||||
|
||||
export const getLocalEmbeddings = async (model: Model) => {
|
||||
export const getLocalEmbeddings = async () => {
|
||||
const embeddings = await getAllLocalEmbeddings();
|
||||
return embeddings.filter((embedding) => embedding.model === model);
|
||||
return embeddings.filter((embedding) => embedding.model === "onnx-clip");
|
||||
};
|
||||
|
||||
const getModelEmbeddingSyncTime = async (model: Model) => {
|
||||
const getModelEmbeddingSyncTime = async (model: EmbeddingModel) => {
|
||||
return (
|
||||
(await localForage.getItem<number>(
|
||||
`${model}-${EMBEDDING_SYNC_TIME_TABLE}`,
|
||||
|
@ -51,11 +51,15 @@ const getModelEmbeddingSyncTime = async (model: Model) => {
|
|||
);
|
||||
};
|
||||
|
||||
const setModelEmbeddingSyncTime = async (model: Model, time: number) => {
|
||||
const setModelEmbeddingSyncTime = async (
|
||||
model: EmbeddingModel,
|
||||
time: number,
|
||||
) => {
|
||||
await localForage.setItem(`${model}-${EMBEDDING_SYNC_TIME_TABLE}`, time);
|
||||
};
|
||||
|
||||
export const syncEmbeddings = async (models: Model[] = [Model.ONNX_CLIP]) => {
|
||||
export const syncEmbeddings = async () => {
|
||||
const models: EmbeddingModel[] = ["onnx-clip"];
|
||||
try {
|
||||
let allEmbeddings = await getAllLocalEmbeddings();
|
||||
const localFiles = await getAllLocalFiles();
|
||||
|
@ -138,7 +142,7 @@ export const syncEmbeddings = async (models: Model[] = [Model.ONNX_CLIP]) => {
|
|||
|
||||
export const getEmbeddingsDiff = async (
|
||||
sinceTime: number,
|
||||
model: Model,
|
||||
model: EmbeddingModel,
|
||||
): Promise<GetEmbeddingDiffResponse> => {
|
||||
try {
|
||||
const token = getToken();
|
||||
|
|
|
@ -4,7 +4,6 @@ import * as chrono from "chrono-node";
|
|||
import { FILE_TYPE } from "constants/file";
|
||||
import { t } from "i18next";
|
||||
import { Collection } from "types/collection";
|
||||
import { Model } from "types/embedding";
|
||||
import { EntityType, LocationTag, LocationTagData } from "types/entity";
|
||||
import { EnteFile } from "types/file";
|
||||
import { Person, Thing } from "types/machineLearning";
|
||||
|
@ -22,7 +21,7 @@ import { getAllPeople } from "utils/machineLearning";
|
|||
import { getMLSyncConfig } from "utils/machineLearning/config";
|
||||
import { getFormattedDate } from "utils/search";
|
||||
import mlIDbStorage from "utils/storage/mlIDbStorage";
|
||||
import { ClipService, computeClipMatchScore } from "./clipService";
|
||||
import { clipService, computeClipMatchScore } from "./clip-service";
|
||||
import { getLocalEmbeddings } from "./embeddingService";
|
||||
import { getLatestEntities } from "./entityService";
|
||||
import locationSearchService, { City } from "./locationSearchService";
|
||||
|
@ -305,7 +304,7 @@ async function getThingSuggestion(searchPhrase: string): Promise<Suggestion[]> {
|
|||
|
||||
async function getClipSuggestion(searchPhrase: string): Promise<Suggestion> {
|
||||
try {
|
||||
if (!ClipService.isPlatformSupported()) {
|
||||
if (!clipService.isPlatformSupported()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -396,8 +395,8 @@ async function searchThing(searchPhrase: string) {
|
|||
}
|
||||
|
||||
async function searchClip(searchPhrase: string): Promise<ClipSearchScores> {
|
||||
const imageEmbeddings = await getLocalEmbeddings(Model.ONNX_CLIP);
|
||||
const textEmbedding = await ClipService.getTextEmbedding(searchPhrase);
|
||||
const imageEmbeddings = await getLocalEmbeddings();
|
||||
const textEmbedding = await clipService.getTextEmbedding(searchPhrase);
|
||||
const clipSearchResult = new Map<number, number>(
|
||||
(
|
||||
await Promise.all(
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
export enum Model {
|
||||
GGML_CLIP = "ggml-clip",
|
||||
ONNX_CLIP = "onnx-clip",
|
||||
}
|
||||
/**
|
||||
* The embeddings models that we support.
|
||||
*
|
||||
* This is an exhaustive set of values we pass when PUT-ting encrypted
|
||||
* embeddings on the server. However, we should be prepared to receive an
|
||||
* {@link EncryptedEmbedding} with a model value distinct from one of these.
|
||||
*/
|
||||
export type EmbeddingModel = "onnx-clip";
|
||||
|
||||
export interface EncryptedEmbedding {
|
||||
fileID: number;
|
||||
model: Model;
|
||||
/** @see {@link EmbeddingModel} */
|
||||
model: string;
|
||||
encryptedEmbedding: string;
|
||||
decryptionHeader: string;
|
||||
updatedAt: number;
|
||||
|
@ -25,7 +30,7 @@ export interface GetEmbeddingDiffResponse {
|
|||
|
||||
export interface PutEmbeddingRequest {
|
||||
fileID: number;
|
||||
model: Model;
|
||||
model: EmbeddingModel;
|
||||
encryptedEmbedding: string;
|
||||
decryptionHeader: string;
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { ensureElectron } from "@/next/electron";
|
||||
import { AppUpdateInfo } from "@/next/types/ipc";
|
||||
import { logoutUser } from "@ente/accounts/services/user";
|
||||
import { DialogBoxAttributes } from "@ente/shared/components/DialogBox/types";
|
||||
|
@ -52,35 +53,34 @@ export const getTrashFileMessage = (deleteFileHelper): DialogBoxAttributes => ({
|
|||
close: { text: t("CANCEL") },
|
||||
});
|
||||
|
||||
export const getUpdateReadyToInstallMessage = (
|
||||
updateInfo: AppUpdateInfo,
|
||||
): DialogBoxAttributes => ({
|
||||
export const getUpdateReadyToInstallMessage = ({
|
||||
version,
|
||||
}: AppUpdateInfo): DialogBoxAttributes => ({
|
||||
icon: <AutoAwesomeOutlinedIcon />,
|
||||
title: t("UPDATE_AVAILABLE"),
|
||||
content: t("UPDATE_INSTALLABLE_MESSAGE"),
|
||||
proceed: {
|
||||
action: () => globalThis.electron?.updateAndRestart(),
|
||||
action: () => ensureElectron().updateAndRestart(),
|
||||
text: t("INSTALL_NOW"),
|
||||
variant: "accent",
|
||||
},
|
||||
close: {
|
||||
text: t("INSTALL_ON_NEXT_LAUNCH"),
|
||||
variant: "secondary",
|
||||
action: () =>
|
||||
globalThis.electron?.muteUpdateNotification(updateInfo.version),
|
||||
action: () => ensureElectron().updateOnNextRestart(version),
|
||||
},
|
||||
});
|
||||
|
||||
export const getUpdateAvailableForDownloadMessage = (
|
||||
updateInfo: AppUpdateInfo,
|
||||
): DialogBoxAttributes => ({
|
||||
export const getUpdateAvailableForDownloadMessage = ({
|
||||
version,
|
||||
}: AppUpdateInfo): DialogBoxAttributes => ({
|
||||
icon: <AutoAwesomeOutlinedIcon />,
|
||||
title: t("UPDATE_AVAILABLE"),
|
||||
content: t("UPDATE_AVAILABLE_MESSAGE"),
|
||||
close: {
|
||||
text: t("IGNORE_THIS_VERSION"),
|
||||
variant: "secondary",
|
||||
action: () => globalThis.electron?.skipAppUpdate(updateInfo.version),
|
||||
action: () => ensureElectron().skipAppUpdate(version),
|
||||
},
|
||||
proceed: {
|
||||
action: downloadApp,
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import log from "@/next/log";
|
||||
import {
|
||||
RecoveryKey,
|
||||
TwoFactorRecoveryResponse,
|
||||
|
@ -62,7 +61,6 @@ export const _logout = async () => {
|
|||
) {
|
||||
return;
|
||||
}
|
||||
log.error("/users/logout failed", e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -70,9 +70,9 @@ export default function Credentials({ appContext, appName }: PageProps) {
|
|||
const electron = globalThis.electron;
|
||||
if (!key && electron) {
|
||||
try {
|
||||
key = await electron.getEncryptionKey();
|
||||
key = await electron.encryptionKey();
|
||||
} catch (e) {
|
||||
log.error("getEncryptionKey failed", e);
|
||||
log.error("Failed to get encryption key from electron", e);
|
||||
}
|
||||
if (key) {
|
||||
await saveKeyInSessionStore(
|
||||
|
|
|
@ -11,49 +11,44 @@ import { PAGES } from "../constants/pages";
|
|||
|
||||
export const logoutUser = async () => {
|
||||
try {
|
||||
try {
|
||||
await _logout();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
InMemoryStore.clear();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
log.error("clear InMemoryStore failed", e);
|
||||
}
|
||||
try {
|
||||
clearKeys();
|
||||
} catch (e) {
|
||||
log.error("clearKeys failed", e);
|
||||
}
|
||||
try {
|
||||
clearData();
|
||||
} catch (e) {
|
||||
log.error("clearData failed", e);
|
||||
}
|
||||
try {
|
||||
await deleteAllCache();
|
||||
} catch (e) {
|
||||
log.error("deleteAllCache failed", e);
|
||||
}
|
||||
try {
|
||||
await clearFiles();
|
||||
} catch (e) {
|
||||
log.error("clearFiles failed", e);
|
||||
}
|
||||
try {
|
||||
globalThis.electron?.clearElectronStore();
|
||||
} catch (e) {
|
||||
log.error("clearElectronStore failed", e);
|
||||
}
|
||||
try {
|
||||
eventBus.emit(Events.LOGOUT);
|
||||
} catch (e) {
|
||||
log.error("Error in logout handlers", e);
|
||||
}
|
||||
router.push(PAGES.ROOT);
|
||||
await _logout();
|
||||
} catch (e) {
|
||||
log.error("logoutUser failed", e);
|
||||
log.error("Ignoring error during POST /users/logout", e);
|
||||
}
|
||||
try {
|
||||
InMemoryStore.clear();
|
||||
} catch (e) {
|
||||
log.error("Ignoring error when clearing in-memory store", e);
|
||||
}
|
||||
try {
|
||||
clearKeys();
|
||||
} catch (e) {
|
||||
log.error("Ignoring error when clearing keys", e);
|
||||
}
|
||||
try {
|
||||
clearData();
|
||||
} catch (e) {
|
||||
log.error("Ignoring error when clearing data", e);
|
||||
}
|
||||
try {
|
||||
await deleteAllCache();
|
||||
} catch (e) {
|
||||
log.error("Ignoring error when clearing caches", e);
|
||||
}
|
||||
try {
|
||||
await clearFiles();
|
||||
} catch (e) {
|
||||
log.error("Ignoring error when clearing files", e);
|
||||
}
|
||||
try {
|
||||
globalThis.electron?.clearStores();
|
||||
} catch (e) {
|
||||
log.error("Ignoring error when clearing electron stores", e);
|
||||
}
|
||||
try {
|
||||
eventBus.emit(Events.LOGOUT);
|
||||
} catch (e) {
|
||||
log.error("Ignoring error in event-bus logout handlers", e);
|
||||
}
|
||||
router.push(PAGES.ROOT);
|
||||
};
|
||||
|
|
|
@ -18,6 +18,33 @@ export const logStartupBanner = (appId: string, userId?: number) => {
|
|||
log.info(`Starting ente-${appIdL}-web ${buildId}uid ${userId ?? 0}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach handlers to log any unhandled exceptions and promise rejections.
|
||||
*
|
||||
* @param attach If true, attach handlers, and if false, remove them. This
|
||||
* allows us to use this in a React hook that cleans up after itself.
|
||||
*/
|
||||
export const logUnhandledErrorsAndRejections = (attach: boolean) => {
|
||||
const handleError = (event: ErrorEvent) => {
|
||||
log.error("Unhandled error", event.error);
|
||||
};
|
||||
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
log.error("Unhandled promise rejection", event.reason);
|
||||
};
|
||||
|
||||
if (attach) {
|
||||
window.addEventListener("error", handleError);
|
||||
window.addEventListener("unhandledrejection", handleUnhandledRejection);
|
||||
} else {
|
||||
window.removeEventListener("error", handleError);
|
||||
window.removeEventListener(
|
||||
"unhandledrejection",
|
||||
handleUnhandledRejection,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: number;
|
||||
logLine: string;
|
||||
|
|
|
@ -10,11 +10,6 @@ export interface AppUpdateInfo {
|
|||
version: string;
|
||||
}
|
||||
|
||||
export enum Model {
|
||||
GGML_CLIP = "ggml-clip",
|
||||
ONNX_CLIP = "onnx-clip",
|
||||
}
|
||||
|
||||
export enum FILE_PATH_TYPE {
|
||||
FILES = "files",
|
||||
ZIPS = "zips",
|
||||
|
@ -42,9 +37,22 @@ export enum PICKED_UPLOAD_TYPE {
|
|||
export interface Electron {
|
||||
// - General
|
||||
|
||||
/** Return the version of the desktop app. */
|
||||
/**
|
||||
* Return the version of the desktop app.
|
||||
*
|
||||
* The return value is of the form `v1.2.3`.
|
||||
*/
|
||||
appVersion: () => Promise<string>;
|
||||
|
||||
/**
|
||||
* Log the given {@link message} to the on-disk log file maintained by the
|
||||
* desktop app.
|
||||
*
|
||||
* Note: Unlike the other functions exposed over the Electron bridge,
|
||||
* logToDisk is fire-and-forget and does not return a promise.
|
||||
*/
|
||||
logToDisk: (message: string) => void;
|
||||
|
||||
/**
|
||||
* Open the given {@link dirPath} in the system's folder viewer.
|
||||
*
|
||||
|
@ -60,13 +68,75 @@ export interface Electron {
|
|||
openLogDirectory: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Log the given {@link message} to the on-disk log file maintained by the
|
||||
* desktop app.
|
||||
* Clear any stored data.
|
||||
*
|
||||
* Note: Unlike the other functions exposed over the Electron bridge,
|
||||
* logToDisk is fire-and-forget and does not return a promise.
|
||||
* This is a coarse single shot cleanup, meant for use in clearing any
|
||||
* Electron side state during logout.
|
||||
*/
|
||||
logToDisk: (message: string) => void;
|
||||
clearStores: () => void;
|
||||
|
||||
/**
|
||||
* Return the previously saved encryption key from persistent safe storage.
|
||||
*
|
||||
* If no such key is found, return `undefined`.
|
||||
*
|
||||
* @see {@link saveEncryptionKey}.
|
||||
*/
|
||||
encryptionKey: () => Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Save the given {@link encryptionKey} into persistent safe storage.
|
||||
*/
|
||||
saveEncryptionKey: (encryptionKey: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Set or clear the callback {@link cb} to invoke whenever the app comes
|
||||
* into the foreground. More precisely, the callback gets invoked when the
|
||||
* main window gets focus.
|
||||
*
|
||||
* Note: Setting a callback clears any previous callbacks.
|
||||
*
|
||||
* @param cb The function to call when the main window gets focus. Pass
|
||||
* `undefined` to clear the callback.
|
||||
*/
|
||||
onMainWindowFocus: (cb?: () => void) => void;
|
||||
|
||||
// - App update
|
||||
|
||||
/**
|
||||
* Set or clear the callback {@link cb} to invoke whenever a new
|
||||
* (actionable) app update is available. This allows the Node.js layer to
|
||||
* ask the renderer to show an "Update available" dialog to the user.
|
||||
*
|
||||
* Note: Setting a callback clears any previous callbacks.
|
||||
*/
|
||||
onAppUpdateAvailable: (
|
||||
cb?: ((updateInfo: AppUpdateInfo) => void) | undefined,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Restart the app to apply the latest available update.
|
||||
*
|
||||
* This is expected to be called in response to {@link onAppUpdateAvailable}
|
||||
* if the user so wishes.
|
||||
*/
|
||||
updateAndRestart: () => void;
|
||||
|
||||
/**
|
||||
* Mute update notifications for the given {@link version}. This allows us
|
||||
* to implement the "Install on next launch" functionality in response to
|
||||
* the {@link onAppUpdateAvailable} event.
|
||||
*/
|
||||
updateOnNextRestart: (version: string) => void;
|
||||
|
||||
/**
|
||||
* Skip the app update with the given {@link version}.
|
||||
*
|
||||
* This is expected to be called in response to {@link onAppUpdateAvailable}
|
||||
* if the user so wishes. It will remember this {@link version} as having
|
||||
* been marked as skipped so that we don't prompt the user again.
|
||||
*/
|
||||
skipAppUpdate: (version: string) => void;
|
||||
|
||||
/**
|
||||
* A subset of filesystem access APIs.
|
||||
|
@ -103,28 +173,6 @@ export interface Electron {
|
|||
* the dataflow.
|
||||
*/
|
||||
|
||||
// - General
|
||||
|
||||
registerForegroundEventListener: (onForeground: () => void) => void;
|
||||
|
||||
clearElectronStore: () => void;
|
||||
|
||||
setEncryptionKey: (encryptionKey: string) => Promise<void>;
|
||||
|
||||
getEncryptionKey: () => Promise<string>;
|
||||
|
||||
// - App update
|
||||
|
||||
updateAndRestart: () => void;
|
||||
|
||||
skipAppUpdate: (version: string) => void;
|
||||
|
||||
muteUpdateNotification: (version: string) => void;
|
||||
|
||||
registerUpdateEventListener: (
|
||||
showUpdateDialog: (updateInfo: AppUpdateInfo) => void,
|
||||
) => void;
|
||||
|
||||
// - Conversion
|
||||
|
||||
convertToJPEG: (
|
||||
|
@ -147,12 +195,27 @@ export interface Electron {
|
|||
|
||||
// - ML
|
||||
|
||||
computeImageEmbedding: (
|
||||
model: Model,
|
||||
imageData: Uint8Array,
|
||||
) => Promise<Float32Array>;
|
||||
/**
|
||||
* Compute and return a CLIP embedding of the given image.
|
||||
*
|
||||
* See: [Note: CLIP based magic search]
|
||||
*
|
||||
* @param jpegImageData The raw bytes of the image encoded as an JPEG.
|
||||
*
|
||||
* @returns A CLIP embedding.
|
||||
*/
|
||||
clipImageEmbedding: (jpegImageData: Uint8Array) => Promise<Float32Array>;
|
||||
|
||||
computeTextEmbedding: (model: Model, text: string) => Promise<Float32Array>;
|
||||
/**
|
||||
* Compute and return a CLIP embedding of the given image.
|
||||
*
|
||||
* See: [Note: CLIP based magic search]
|
||||
*
|
||||
* @param text The string whose embedding we want to compute.
|
||||
*
|
||||
* @returns A CLIP embedding.
|
||||
*/
|
||||
clipTextEmbedding: (text: string) => Promise<Float32Array>;
|
||||
|
||||
// - File selection
|
||||
// TODO: Deprecated - use dialogs on the renderer process itself
|
||||
|
|
|
@ -103,7 +103,7 @@ export const saveKeyInSessionStore = async (
|
|||
setKey(keyType, sessionKeyAttributes);
|
||||
const electron = globalThis.electron;
|
||||
if (electron && !fromDesktop && keyType === SESSION_KEYS.ENCRYPTION_KEY) {
|
||||
electron.setEncryptionKey(key);
|
||||
electron.saveEncryptionKey(key);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue