[desktop] Video generation integration - WIP Part x/x (#5896)

This commit is contained in:
Manav Rathi
2025-05-13 17:22:22 +05:30
committed by GitHub
10 changed files with 370 additions and 94 deletions

View File

@@ -227,6 +227,8 @@ const ffmpegGenerateHLSPlaylistAndSegments = async (
// going to use for the conversion), then a streaming variant is not much
// use. Skip such cases.
//
// See also: [Note: Marking files which do not need video processing]
//
// ---
//
// [Note: HEVC/H.265 issues]

View File

@@ -7,6 +7,7 @@ import HealthAndSafetyIcon from "@mui/icons-material/HealthAndSafety";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import NorthEastIcon from "@mui/icons-material/NorthEast";
import ScienceIcon from "@mui/icons-material/Science";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
import {
Box,
@@ -33,6 +34,7 @@ import {
RowButtonDivider,
RowButtonGroup,
RowButtonGroupHint,
RowButtonGroupTitle,
RowSwitch,
} from "ente-base/components/RowButton";
import { SpacedRow } from "ente-base/components/containers";
@@ -52,6 +54,7 @@ import {
import { useBaseContext } from "ente-base/context";
import {
getLocaleInUse,
pt,
setLocaleInUse,
supportedLocales,
ut,
@@ -61,6 +64,10 @@ import log from "ente-base/log";
import { savedLogs } from "ente-base/log-web";
import { customAPIHost } from "ente-base/origins";
import { downloadString } from "ente-base/utils/web";
import {
isHLSGenerationSupported,
toggleHLSGeneration,
} from "ente-gallery/services/video";
import { DeleteAccount } from "ente-new/photos/components/DeleteAccount";
import { DropdownInput } from "ente-new/photos/components/DropdownInput";
import { MLSettings } from "ente-new/photos/components/sidebar/MLSettings";
@@ -71,6 +78,7 @@ import {
} from "ente-new/photos/components/utils/dialog";
import { downloadAppDialogAttributes } from "ente-new/photos/components/utils/download";
import {
useHLSGenerationStatusSnapshot,
useSettingsSnapshot,
useUserDetailsSnapshot,
} from "ente-new/photos/components/utils/use-snapshot";
@@ -769,6 +777,9 @@ const Preferences: React.FC<NestedSidebarDrawerVisibilityProps> = ({
const { show: showMLSettings, props: mlSettingsVisibilityProps } =
useModalVisibility();
const hlsGenStatusSnapshot = useHLSGenerationStatusSnapshot();
const isHLSGenerationEnabled = !!hlsGenStatusSnapshot?.enabled;
useEffect(() => {
if (open) void syncSettings();
}, [open]);
@@ -809,6 +820,22 @@ const Preferences: React.FC<NestedSidebarDrawerVisibilityProps> = ({
label={t("advanced")}
onClick={showAdvancedSettings}
/>
{isHLSGenerationSupported() && (
<Stack>
<RowButtonGroupTitle icon={<ScienceIcon />}>
{t("labs")}
</RowButtonGroupTitle>
<RowButtonGroup>
<RowSwitch
label={
/* TODO(HLS): */ pt("Streamable videos")
}
checked={isHLSGenerationEnabled}
onClick={() => void toggleHLSGeneration()}
/>
</RowButtonGroup>
</Stack>
)}
</Stack>
</Stack>
<MapSettings

View File

@@ -46,6 +46,10 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { resumeExportsIfNeeded } from "services/export";
import { photosLogout } from "services/logout";
import {
initVideoProcessing,
isHLSGenerationSupportedTemp,
} from "ente-gallery/services/video";
import "photoswipe/dist/photoswipe.css";
import "styles/global.css";
import "styles/photoswipe.css";
@@ -108,6 +112,7 @@ const App: React.FC<AppProps> = ({ Component, pageProps }) => {
};
if (isMLSupported) initML();
if (isHLSGenerationSupportedTemp()) void initVideoProcessing();
electron.onOpenEnteURL(handleOpenEnteURL);
electron.onAppUpdateAvailable(showUpdateDialog);

View File

@@ -3,11 +3,12 @@
* folder by appending a temporary <a> element to the DOM.
*
* @param url The URL that we want to download. See also
* {@link downloadAndRevokeObjectURL} and {@link downloadString}.
* {@link downloadAndRevokeObjectURL} and {@link downloadString}. The URL is
* revoked after initiating the download.
*
* @param fileName The name of downloaded file.
*/
export const downloadURL = (url: string, fileName: string) => {
export const downloadAndRevokeObjectURL = (url: string, fileName: string) => {
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
@@ -18,15 +19,6 @@ export const downloadURL = (url: string, fileName: string) => {
a.remove();
};
/**
* A variant of {@link downloadURL} that also revokes the provided
* {@link objectURL} after initiating the download.
*/
export const downloadAndRevokeObjectURL = (url: string, fileName: string) => {
downloadURL(url, fileName);
URL.revokeObjectURL(url);
};
/**
* Save the given string {@link s} as a file in the user's download folder.
*

View File

@@ -4,7 +4,7 @@ import { downloadManager } from "ente-gallery/services/download";
import { extractRawExif, parseExif } from "ente-gallery/services/exif";
import {
hlsPlaylistDataForFile,
type HLSPlaylistData,
type HLSPlaylistDataForFile,
} from "ente-gallery/services/video";
import type { EnteFile } from "ente-media/file";
import { fileCaption, filePublicMagicMetadata } from "ente-media/file-metadata";
@@ -452,10 +452,14 @@ const enqueueUpdates = async (
const updateVideo = (
videoURL: string | undefined,
hlsPlaylistData: HLSPlaylistData | undefined,
hlsPlaylistData: HLSPlaylistDataForFile,
) => {
const videoURLD = videoURL ? { videoURL } : {};
if (hlsPlaylistData) {
// See: [Note: Caching HLS playlist data]
//
// In brief, there are three cases:
if (typeof hlsPlaylistData == "object") {
// 1. If we have a playlist, we can cache it
const {
playlistURL: videoPlaylistURL,
width,
@@ -466,18 +470,9 @@ const enqueueUpdates = async (
createHLSPlaylistItemDataValidity(),
);
} else {
// See: [Note: Caching HLS playlist data]
//
// TODO(HLS): As an optimization, we can handle the logged in vs
// public albums case separately once we have the status-diff state,
// we don't need to mark status-diff case as transient.
//
// Note that setting the transient flag is not too expensive, since
// the underlying videoURL is still cached by the download manager.
// So effectively, under normal circumstance, it just adds one API
// call (to recheck if an HLS playlist now exists for the given
// file).
update({ ...videoURLD, isTransient: true });
// 2. if the file is not eligible ("skip"), we can cache it.
// 3. Otherwise it's transient and shouldn't be cached indefinitely.
update({ ...videoURLD, isTransient: hlsPlaylistData != "skip" });
}
};
@@ -526,7 +521,7 @@ const enqueueUpdates = async (
}
try {
let hlsPlaylistData: HLSPlaylistData | undefined;
let hlsPlaylistData: HLSPlaylistDataForFile;
if (file.metadata.fileType == FileType.video) {
hlsPlaylistData = await hlsPlaylistDataForFile(
file,
@@ -534,7 +529,10 @@ const enqueueUpdates = async (
);
// We have a HLS playlist, and the user didn't request the original.
// Early return so that we don't initiate a fetch for the original.
if (hlsPlaylistData && opts?.videoQuality != "original") {
if (
typeof hlsPlaylistData == "object" &&
opts?.videoQuality != "original"
) {
updateVideo(undefined, hlsPlaylistData);
return;
}

View File

@@ -8,10 +8,14 @@ import {
retryAsyncOperation,
type PublicAlbumsCredentials,
} from "ente-base/http";
import { getKV, getKVN, setKV } from "ente-base/kv";
import { getKV, getKVB, getKVN, setKV } from "ente-base/kv";
import { ensureLocalUser } from "ente-base/local-user";
import log from "ente-base/log";
import { fileLogID, type EnteFile } from "ente-media/file";
import {
filePublicMagicMetadata,
updateRemotePublicMagicMetadata,
} from "ente-media/file-metadata";
import { FileType } from "ente-media/file-type";
import {
getAllLocalFiles,
@@ -45,6 +49,12 @@ import {
type TimestampedFileSystemUploadItem,
} from "./upload";
export type HLSGenerationEnabledStatus = "processing" | "idle";
export type HLSGenerationStatus =
| { enabled: false }
| { enabled: true; status?: HLSGenerationEnabledStatus };
interface VideoProcessingQueueItem {
/**
* The {@link EnteFile} (guaranteed to be of {@link FileType.video}) whose
@@ -71,6 +81,25 @@ const idleWaitMax = idleWaitInitial * 2 ** 6; /* 640 sec */
* This entire object will be reset on logout.
*/
class VideoState {
/**
* `true` if the generation of HLS streams has been enabled on this client.
*/
isHLSGenerationEnabled = false;
/**
* Subscriptions to {@link HLSGenerationStatus} updates attached using
* {@link hlsGenerationStatusSubscribe}.
*/
hlsGenerationStatusListeners: (() => void)[] = [];
/**
* Snapshot of the {@link HLSGenerationStatus} returned by the
* {@link hlsGenerationStatusSnapshot} function.
*/
hlsGenerationStatusSnapshot: HLSGenerationStatus | undefined;
/**
* Value of the {@link status} field in the last
* {@link hlsGenerationStatusSnapshot}.
*/
lastEnabledStatus: HLSGenerationEnabledStatus | undefined;
/**
* Queue of recently uploaded items waiting to be processed.
*/
@@ -125,6 +154,136 @@ export const resetVideoState = () => {
_state = new VideoState();
};
/**
* A function that can be used to subscribe to updates in the HLS generation
* settings and status.
*
* See: [Note: Snapshots and useSyncExternalStore].
*/
export const hlsGenerationStatusSubscribe = (
onChange: () => void,
): (() => void) => {
_state.hlsGenerationStatusListeners.push(onChange);
return () => {
_state.hlsGenerationStatusListeners =
_state.hlsGenerationStatusListeners.filter((l) => l != onChange);
};
};
/**
* Return the last know, cached {@link HLSGenerationStatus}.
*
* See also {@link hlsGenerationStatusSubscribe}.
*
* This function can be safely called even if {@link isHLSGenerationSupported}
* is `false` (in such cases, it will always return `undefined`). This is so
* that it can be unconditionally called as part of a React hook.
*
* A return value of `undefined` indicates that the HLS generation subsystem has
* not been initialized yet.
*/
export const hlsGenerationStatusSnapshot = () =>
_state.hlsGenerationStatusSnapshot;
const setHLSGenerationStatusSnapshot = (snapshot: HLSGenerationStatus) => {
_state.hlsGenerationStatusSnapshot = snapshot;
_state.hlsGenerationStatusListeners.forEach((l) => l());
};
/**
* A variant of {@link setHLSGenerationStatusSnapshot} that only triggers an
* update of the snapshot if the enabled state is different from the last known
* enabled state.
*/
const updateSnapshotIfNeeded = (
status: HLSGenerationEnabledStatus | undefined,
) => {
const enabled = _state.isHLSGenerationEnabled;
if (enabled && status != _state.lastEnabledStatus) {
_state.lastEnabledStatus = status;
setHLSGenerationStatusSnapshot({ enabled, status });
}
};
/**
* Return `true` if this client is capable of generating HLS streams for
* uploaded videos.
*
* This function implementation is fast and can be called many times (e.g.
* during UI rendering).
*/
export const isHLSGenerationSupported = () =>
// Keep this check fast, we get called many times.
isDesktop &&
// TODO(HLS):
process.env.NEXT_PUBLIC_ENTE_WIP_VIDEO_STREAMING &&
settingsSnapshot().isInternalUser;
// TODO(HLS): Only the isDesktop flag is needed eventually.
export const isHLSGenerationSupportedTemp = () =>
isDesktop &&
// TODO(HLS):
process.env.NEXT_PUBLIC_ENTE_WIP_VIDEO_STREAMING;
/**
* Initialize the video processing subsystem if the user has enabled HLS
* generation in settings.
*/
export const initVideoProcessing = async () => {
let enabled = false;
if (await savedGenerateHLS()) enabled = true;
_state.isHLSGenerationEnabled = enabled;
// Update snapshot to reflect the enabled setting. The status will get
// filled in when we tick.
setHLSGenerationStatusSnapshot({ enabled });
};
/**
* Return the persisted user preference for HLS generation.
*/
const savedGenerateHLS = () => getKVB("generateHLS");
/**
* Update the persisted user preference for HLS generation.
*
* Use {@link savedGenerateHLS} to get the persisted value back.
*/
const saveGenerateHLS = (enabled: boolean) => setKV("generateHLS", enabled);
/**
* Enable or disable (toggle) the HLS generation on this client.
*
* When HLS generation is enabled, this client will process videos to generate a
* streamable variant of them.
*
* Precondition: {@link isHLSGenerationSupported} must be `true`.
*/
export const toggleHLSGeneration = async () => {
if (!isHLSGenerationSupported()) {
assertionFailed();
return;
}
const enabled = !_state.isHLSGenerationEnabled;
// Clear transient fields.
_state.lastEnabledStatus = undefined;
// Update disk.
await saveGenerateHLS(enabled);
// Update in memory.
_state.isHLSGenerationEnabled = enabled;
// Update snapshot. Right now we only set the enabled setting. The status
// will get filled in when we tick.
setHLSGenerationStatusSnapshot({ enabled });
// Wake up the processor if needed.
if (enabled) tickNow();
};
export interface HLSPlaylistData {
/** A data URL to a HLS playlist that streams the video. */
playlistURL: string;
@@ -134,6 +293,11 @@ export interface HLSPlaylistData {
height: number;
}
/**
* See: [Note: Caching HLS playlist data] for the semantics of "skip".
*/
export type HLSPlaylistDataForFile = HLSPlaylistData | "skip" | undefined;
/**
* Return a HLS playlist that can be used to stream playback of then given video
* {@link file}.
@@ -167,34 +331,35 @@ export interface HLSPlaylistData {
* regenerated again). All in all, this means that a positive result ("this
* file has a playlist") can be cached indefinitely.
*
* - If a file does not have a HLS playlist, and it is eligible for being
* streamed (e.g. it is not too small where the streaming overhead is not
* required), then a client (this one, or a different one) can process it at
* any arbitrary time. So the negative result ("this file does not have a
* - If a file does not have a HLS playlist, it might be because it is not
* eligible for being streamed (e.g. it is already small and in a compatible
* codec). See [Note: Marking files which do not need video processing] for
* more details of this case. In particular, we can cache this state in memory
* indefinitely too, since there isn't a current case where either this
* eligibility can change, or the client gain the ability to handle them
* without restarting.
*
* - Finally, if a file does not have an HLS playlist but and it is eligible for
* being streamed, then a client (this one, or a different one) can process it
* at any arbitrary time. So the negative result ("this file does not have a
* playlist") cannot be cached.
*
* So while we can easily cache the first case ("this file has a playlist"), we
* need to deal with the second case ("this file does not have a playlist") a
* bit more intricately:
*
* - If running in the context of a logged in user (e.g. photos app), we can use
* the "/files/data/status-diff" API to be notified of any modifications to
* the second case for the user's own files. This status-diff happens during
* the regular "sync", and we can use that as a cue to selectively prune cache
* entries for the second case (but can otherwise indefinitely cache it).
*
* - If the file is a shared file, the status-diff will not return it. And if
* we're not running in the context of a logged in user (e.g. the public
* albums app), then there is no status-diff to do. For these two scenarios,
* we thus mark the cached values as "transient" and always recheck for a
* playlist when opening the slide.
* So while we can easily cache the first case ("this file has a playlist") and
* second case ("this file doesn't need a streaming variant"), we need to deal
* with the third case ("this file does not have a playlist") by marking the
* cached values as "transient" and always recheck for a playlist when opening
* the slide.
*/
export const hlsPlaylistDataForFile = async (
file: EnteFile,
publicAlbumsCredentials?: PublicAlbumsCredentials,
): Promise<HLSPlaylistData | undefined> => {
): Promise<HLSPlaylistDataForFile> => {
ensurePrecondition(file.metadata.fileType == FileType.video);
if (filePublicMagicMetadata(file)?.sv == 1) {
return "skip";
}
const playlistFileData = await fetchFileData(
"vid_preview",
file.id,
@@ -511,7 +676,7 @@ const syncProcessedFileIDs = async () =>
export const videoPrunePermanentlyDeletedFileIDsIfNeeded = async (
deletedFileIDs: Set<number>,
) => {
if (!isDesktop) return;
if (!isHLSGenerationSupported()) return;
const existing = await savedProcessedVideoFileIDs();
if (existing.size > 0) {
@@ -540,11 +705,18 @@ export const videoPrunePermanentlyDeletedFileIDsIfNeeded = async (
* that have already been processed elsewhere.
*/
export const videoProcessingSyncIfNeeded = async () => {
if (!isDesktop) return;
if (!isVideoProcessingEnabled()) return;
if (!isHLSGenerationSupported()) return;
// The `haveSyncedOnce` flag tracks whether or not a sync has happened for
// the app, and is not specific to video processing. We always set it even
// if HLS generation is currently disabled so that we can immediately start
// processing the backfill if it gets video processing gets enabled during
// the app's session, without waiting for the next sync to happen.
_state.haveSyncedOnce = true;
if (!isHLSGenerationEnabled()) return;
await syncProcessedFileIDs();
_state.haveSyncedOnce = true;
tickNow(); /* if not already ticking */
};
@@ -576,8 +748,8 @@ export const processVideoNewUpload = (
file: EnteFile,
processableUploadItem: ProcessableUploadItem,
) => {
if (!isDesktop) return;
if (!isVideoProcessingEnabled()) return;
if (!isHLSGenerationSupported()) return;
if (!isHLSGenerationEnabled()) return;
if (file.metadata.fileType !== FileType.video) return;
if (processableUploadItem instanceof File) {
// While the types don't guarantee it, we really shouldn't be getting
@@ -622,10 +794,7 @@ const tickNow = () => {
_state.queueProcessor ??= processQueue();
};
export const isVideoProcessingEnabled = () =>
// TODO(HLS):
process.env.NEXT_PUBLIC_ENTE_WIP_VIDEO_STREAMING &&
settingsSnapshot().isInternalUser;
export const isHLSGenerationEnabled = () => _state.isHLSGenerationEnabled;
/**
* The video processing loop goes through videos one by one, preferring items in
@@ -664,7 +833,7 @@ export const isVideoProcessingEnabled = () =>
* batches, and the externally triggered processing of live uploads.
*/
const processQueue = async () => {
if (!(isDesktop && isVideoProcessingEnabled())) {
if (!isHLSGenerationSupported() || !isHLSGenerationEnabled()) {
assertionFailed(); /* we shouldn't have come here */
return;
}
@@ -672,32 +841,23 @@ const processQueue = async () => {
const userID = ensureLocalUser().id;
let bq: typeof _state.liveQueue | undefined;
while (isVideoProcessingEnabled()) {
while (isHLSGenerationEnabled()) {
let item = _state.liveQueue.shift();
if (!item) {
if (!bq && _state.haveSyncedOnce) {
/* initialize */
bq = await backfillQueue(userID);
}
if (bq) {
switch (bq.length) {
case 0:
/* no more items to backfill */
break;
case 1 /* last item. take it, and refill queue */:
item = bq.pop();
bq = await backfillQueue(userID);
break;
default:
/* more than one item. take it */
item = bq.pop();
break;
// Initialize or refill queue.
if (!bq?.length) {
if (_state.haveSyncedOnce) {
bq = await backfillQueue(userID);
} else {
log.info("Not attempting backfill until first sync");
}
} else {
log.info("Not backfilling since we haven't synced yet");
}
// Take item if queue is not empty.
if (bq?.length) item = bq.pop();
}
if (item) {
updateSnapshotIfNeeded("processing");
try {
await processQueueItem(item);
await markProcessedVideoFileID(item.file.id);
@@ -711,6 +871,8 @@ const processQueue = async () => {
// There are no more items in either the live queue or backlog.
// Go to sleep (for increasingly longer durations, capped at a
// maximum).
updateSnapshotIfNeeded("idle");
const idleWait = _state.idleWait;
_state.idleWait = Math.min(idleWait * 2, idleWaitMax);
@@ -724,6 +886,8 @@ const processQueue = async () => {
}
}
updateSnapshotIfNeeded(undefined);
_state.queueProcessor = undefined;
};
@@ -743,9 +907,14 @@ const backfillQueue = async (
const videoFiles = uniqueFilesByID(
allCollectionFiles.filter(
(f) =>
// Only files the user owns.
f.ownerID == userID &&
// Only videos.
f.metadata.fileType == FileType.video &&
!localTrashFileIDs.has(f.id),
// Not in trash.
!localTrashFileIDs.has(f.id) &&
// See: [Note: Marking files which do not need video processing]
filePublicMagicMetadata(f)?.sv != 1,
),
);
@@ -856,6 +1025,8 @@ const processQueueItem = async ({
if (!res) {
log.info(`Generate HLS for ${fileLogID(file)} | not-required`);
// See: [Note: Marking files which do not need video processing]
await updateRemotePublicMagicMetadata(file, { sv: 1 });
return;
}

View File

@@ -274,6 +274,33 @@ export interface PublicMagicMetadata {
*/
caption?: string;
uploaderName?: string;
/**
* An arbitrary integer set to indicate that this file should be skipped for
* the purpose of HLS generation.
*
* Current semantics:
*
* - if 1, skip this file
* - otherwise attempt processing
*
* [Note: Marking files which do not need video processing]
*
* Some video files do not require generation of a HLS stream. The current
* logic is H.264 files less than 10 MB, but this might change in future
* clients.
*
* For such skipped files, there thus won't be a HLS playlist generated.
* However, we still need a way to indicate to other clients that this file
* has already been looked at.
*
* To that end, we add a flag to the public magic metadata for the file. To
* allow future flexibility, this flag is an integer "streaming version".
* Currently it is set to 1 by a client who recognizes that this file does
* not need processing, and other clients can ignore this file if they find
* sv == 1. In the future, there might be other values for sv (e.g. if the
* skip logic changes).
*/
sv?: number;
}
/**

View File

@@ -13,10 +13,14 @@ import {
useTheme,
type Theme,
} from "@mui/material";
import { assertionFailed } from "ente-base/assert";
import { EnteLogo, EnteLogoBox } from "ente-base/components/EnteLogo";
import type { ButtonishProps } from "ente-base/components/mui";
import { useIsSmallWidth } from "ente-base/components/utils/hooks";
import { pt } from "ente-base/i18n";
import {
hlsGenerationStatusSnapshot,
isHLSGenerationSupported,
} from "ente-gallery/services/video";
import { ItemCard, PreviewItemTile } from "ente-new/photos/components/Tiles";
import { isMLSupported, mlStatusSnapshot } from "ente-new/photos/services/ml";
import { searchOptionsForString } from "ente-new/photos/services/search";
@@ -38,6 +42,7 @@ import AsyncSelect from "react-select/async";
import { SearchPeopleList } from "./PeopleList";
import { UnstyledButton } from "./UnstyledButton";
import {
useHLSGenerationStatusSnapshot,
useMLStatusSnapshot,
usePeopleStateSnapshot,
} from "./utils/use-snapshot";
@@ -382,11 +387,26 @@ const shouldShowEmptyState = (inputValue: string) => {
// Don't show empty state if the user has entered search input.
if (inputValue) return false;
// Don't show empty state if there is no ML related information.
if (!isMLSupported) return false;
// Don't show empty state if there is no ML related information AND we're
// not processing videos.
const status = mlStatusSnapshot();
if (!status || status.phase == "disabled") return false;
if (!isMLSupported && !isHLSGenerationSupported()) {
// Neither of ML or HLS generation is supported on current client. This
// is the code path for web.
return false;
}
const mlStatus = mlStatusSnapshot();
const vpStatus = hlsGenerationStatusSnapshot();
if (
(!mlStatus || mlStatus.phase == "disabled") &&
(!vpStatus?.enabled || vpStatus.status != "processing")
) {
// ML is either not supported or currently disabled AND video processing
// is either not supported or currently not happening. Don't show the
// empty state.
return false;
}
// Show it otherwise.
return true;
@@ -401,15 +421,19 @@ const EmptyState: React.FC<
> = ({ onSelectPeople, onSelectPerson }) => {
const mlStatus = useMLStatusSnapshot();
const people = usePeopleStateSnapshot()?.visiblePeople;
if (!mlStatus || mlStatus.phase == "disabled") {
// The preflight check should've prevented us from coming here.
assertionFailed();
return <></>;
}
const vpStatus = useHLSGenerationStatusSnapshot();
let label: string | undefined;
switch (mlStatus.phase) {
switch (mlStatus?.phase) {
case undefined:
case "disabled":
case "done":
// If ML is not running, see if video processing is.
if (vpStatus?.enabled && vpStatus.status == "processing") {
// TODO(HLS):
label = pt("Processing videos...");
}
break;
case "scheduled":
label = t("indexing_scheduled");
break;
@@ -424,6 +448,12 @@ const EmptyState: React.FC<
break;
}
// If ML is disabled and we're not video processing, then don't show the
// empty state content.
if ((!mlStatus || mlStatus.phase == "disabled") && !label) {
return <></>;
}
return (
<Box sx={{ textAlign: "left" }}>
{people && people.length > 0 && (
@@ -473,7 +503,11 @@ const OptionContents = ({ data: option }: { data: SearchOption }) => (
>
<Box>
<Typography
sx={{ fontWeight: "medium", wordBreak: "break-word" }}
sx={{
color: "text.base",
fontWeight: "medium",
wordBreak: "break-word",
}}
>
{option.suggestion.label}
</Typography>

View File

@@ -1,3 +1,7 @@
import {
hlsGenerationStatusSnapshot,
hlsGenerationStatusSubscribe,
} from "ente-gallery/services/video";
import { useSyncExternalStore } from "react";
import {
mlStatusSnapshot,
@@ -38,3 +42,13 @@ export const useMLStatusSnapshot = () =>
*/
export const usePeopleStateSnapshot = () =>
useSyncExternalStore(peopleStateSubscribe, peopleStateSnapshot);
/**
* A convenience hook that returns {@link hlsGenerationStatusSnapshot}, and also
* subscribes to updates.
*/
export const useHLSGenerationStatusSnapshot = () =>
useSyncExternalStore(
hlsGenerationStatusSubscribe,
hlsGenerationStatusSnapshot,
);

View File

@@ -471,10 +471,16 @@ export const mlStatusSubscribe = (onChange: () => void): (() => void) => {
*
* See also {@link mlStatusSubscribe}.
*
* This function can be safely called even if {@link isMLSupported} is `false`
* (in such cases, it will always return `undefined`). This is so that it can be
* unconditionally called as part of a React hook.
*
* A return value of `undefined` indicates that we're still performing the
* asynchronous tasks that are needed to get the status.
*/
export const mlStatusSnapshot = (): MLStatus | undefined => {
if (!isMLSupported) return undefined;
const result = _state.mlStatusSnapshot;
// We don't have it yet, trigger an update.
if (!result) triggerStatusUpdate();